diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..7c05da9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": "@artus/eslint-config-artus/typescript", + "parserOptions": { + "project": "./tsconfig.json", + "createDefaultProgram": true + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e370466 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [ master, main ] + + pull_request: + branches: [ master, main, next, beta, "*.x" ] + + schedule: + - cron: '0 2 * * *' + + workflow_dispatch: + +jobs: + Job: + name: Node.js + uses: artusjs/github-actions/.github/workflows/node-test.yml@master + # pass these inputs only if you need to custom + # with: + # os: 'ubuntu-latest, macos-latest, windows-latest' + # version: '16, 18' diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml deleted file mode 100644 index 7218472..0000000 --- a/.github/workflows/nodejs.yml +++ /dev/null @@ -1,47 +0,0 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: Node.js CI - -on: - workflow_dispatch: {} - push: - branches: - - main - - master - pull_request: - branches: - - main - - master - schedule: - - cron: '0 2 * * *' - -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - node-version: [14, 16, 18] - os: [ubuntu-latest, windows-latest, macos-latest] - - steps: - - name: Checkout Git Source - uses: actions/checkout@v2 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Install Dependencies - run: npm i - - - name: Continuous Integration - run: npm run ci - - - name: Code Coverage - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index f44b8bf..cfce3c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -logs/ -npm-debug.log node_modules/ +dist/ coverage/ -.idea/ -run/ -.DS_Store -*.swp -*-lock.json -*-lock.yaml -.vscode/history +*-lock*[.yaml, .json] +**/*.js +**/*.js.map +**/*.d.ts .tmp -.vscode -.tempCodeRunnerFile.js -/snippet/ diff --git a/.mocharc.cjs b/.mocharc.cjs new file mode 100644 index 0000000..a88b0c1 --- /dev/null +++ b/.mocharc.cjs @@ -0,0 +1,9 @@ +process.env.TS_NODE_PROJECT = 'test/tsconfig.json'; + +module.exports = { + spec: 'test/**/*.test.ts', + extension: 'ts', + require: 'ts-node/register', + timeout: 120000, + exclude: 'test/fixtures/', +}; diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..babf456 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,29 @@ + // prepare/prerun + // - init dir, init env, init ctx + // run + // - run cli + // - collect stdout/stderr, emit event + // - stdin (expect) + // postrun + // - wait event(end, message, error, stdout, stderr) + // - check assert + // end + // - clean up, kill, log result, error hander + + // console.log(this.middlewares); + // return Promise.all(this.middlewares.map(fn => fn())); + + // prepare: [], + // prerun: [], + // run: [], + // postrun: [], + +// koa middleware +// 初始化 -> fork -> await next() -> 校验 -> 结束 + + + // prepare 准备现场环境 + // prerun 检查参数,在 fork 定义之后 + // run 处理 stdin + // postrun 检查 assert + // end 检查 code,清理现场,相当于 finnaly diff --git a/Untitled-1.ts b/Untitled-1.ts new file mode 100644 index 0000000..1a95c57 --- /dev/null +++ b/Untitled-1.ts @@ -0,0 +1,51 @@ + +class TestRunner { + // TODO: write a gymnastics for this. + plugin(plugins) { + for (const key of Object.keys(plugins)) { + const initFn = plugins[key]; + this[key] = (...args) => { + initFn(this, ...args); + return this; + }; + } + return this; + } + end() { + console.log('done'); + } +} + +// test case +const test_plugins = { + stdout(runner: TestRunner, expected: string) { + console.log('stdout', expected); + }, + code(runner: TestRunner, expected: number) { + console.log('code', expected); + }, +}; + +new TestRunner() + .plugin({ ...test_plugins }) + // should know the type of arg1 + .stdout('a test') + // should invalid, expected is not string + .stdout(/aaaa/) + .code(0) + .end(); + +// invalid case +const invalidPlugin = { + // invalid plugin, the first params should be runner: TestRunner + xx: (str: string) => { + console.log('### xx', typeof str); + }, +}; + +new TestRunner() + .plugin({ ...test_plugins, ...invalidPlugin }) + .stdout('a test') + .xx('a test') + .code(0) + .end(); diff --git a/Untitled-2.ts b/Untitled-2.ts new file mode 100644 index 0000000..4c14a13 --- /dev/null +++ b/Untitled-2.ts @@ -0,0 +1,61 @@ + +type MountPlugin = { + [key in keyof T]: T[key] extends (core: TestRunner, ...args: infer I) => any ? (...args: I) => MountPlugin : undefined; +} & TestRunner; + +interface PluginLike { + [key: string]: (core: any, ...args: any[]) => any; +} + +type RestParam = T extends (first: any, ...args: infer R) => any ? R : any; + +class TestRunner { + // TODO: write a gymnastics for this. + plugin(plugins: T): MountPlugin { + for (const key of Object.keys(plugins)) { + const initFn = plugins[key]; + (this as any)[key] = (...args: RestParam) => { + initFn(this, ...args); + return this; + }; + } + return this as any; + } + end() { + console.log('done'); + } +} + +// test case +const test_plugins = { + stdout(runner: TestRunner, expected: string) { + console.log('stdout', expected); + }, + code(runner: TestRunner, expected: number) { + console.log('code', expected); + }, +}; + +new TestRunner() + .plugin({ ...test_plugins } satisfies PluginLike) + // should know the type of arg1 + .stdout('a test') + // should invalid, expected is not string + .stdout(/aaaa/) + .code(0) + .end(); + +// invalid case +const invalidPlugin = { + // invalid plugin, the first params should be runner: TestRunner + xx: (str: string) => { + console.log('### xx', typeof str); + }, +}; + +new TestRunner() + .plugin({ ...test_plugins, ...invalidPlugin } satisfies PluginLike) + .stdout('a test') + .xx('a test') + .code(0) + .end(); diff --git a/lib/runner.js b/lib/runner.js index 4a8fb62..24e3875 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -5,10 +5,10 @@ import stripAnsi from 'strip-ansi'; import stripFinalNewline from 'strip-final-newline'; import { pEvent } from 'p-event'; import { compose } from 'throwback'; +import consola from 'consola'; import * as utils from './utils.js'; import { assert } from './assert.js'; -import { Logger, LogLevel } from './logger.js'; import * as validatorPlugin from './validator.js'; import * as operationPlugin from './operation.js'; @@ -22,7 +22,7 @@ class TestRunner extends EventEmitter { this.assert = assert; this.utils = utils; - this.logger = new Logger({ tag: 'CLET' }); + this.logger = consola.withDefaults({ tag: 'CLET' }); this.childLogger = this.logger.child('PROC', { indent: 4, showTag: false }); // middleware.pre -> before -> fork -> running -> after -> end -> middleware.post -> cleanup diff --git a/lib/utils.js b/lib/utils.js index e62775b..781f79b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,8 +1,7 @@ -import { promises as fs } from 'fs'; -import { types } from 'util'; -import path from 'path'; +import fs from 'node:fs/promises'; +import { types } from 'node:util'; +import path from 'node:path'; -import { dirname } from 'dirname-filename-esm'; import isMatch from 'lodash.ismatch'; import trash from 'trash'; @@ -12,6 +11,7 @@ types.isFunction = function(v) { return typeof v === 'function'; }; export { types, isMatch }; + /** * validate input with expected rules * @@ -103,17 +103,17 @@ export async function exists(filePath) { } } -/** - * resolve file path by import.meta, kind of __dirname for esm - * - * @param {Object} meta - import.meta - * @param {...String} args - other paths - * @return {String} file path - */ -export function resolve(meta, ...args) { - const p = types.isObject(meta) ? dirname(meta) : meta; - return path.resolve(p, ...args); -} +// /** +// * resolve file path by import.meta, kind of __dirname for esm +// * +// * @param {Object} meta - import.meta +// * @param {...String} args - other paths +// * @return {String} file path +// */ +// export function resolve(meta, ...args) { +// const p = types.isObject(meta) ? dirname(meta) : meta; +// return path.resolve(p, ...args); +// } /** * take a sleep diff --git a/lib/validator.js b/lib/validator.js index 94c8f4f..1b2df3f 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -21,7 +21,7 @@ export function expect(fn) { } const extractPathRegex = /\s+at.*[(\s](.*):\d+:\d+\)?/; -const __filename = filename(import.meta); +const currentFileName = types.isObject(meta) ? filename(import.meta) : __filename; function mergeError(buildError, runError) { buildError.message = runError.message; @@ -38,7 +38,7 @@ function mergeError(buildError, runError) { if (line.trim() === '') return false; const pathMatches = line.match(extractPathRegex); if (pathMatches === null || !pathMatches[1]) return true; - if (pathMatches[1] === __filename) return false; + if (pathMatches[ 1 ] === currentFileName) return false; return true; }) .join('\n'); diff --git a/package.json b/package.json index 8043d99..3b9a910 100644 --- a/package.json +++ b/package.json @@ -2,73 +2,60 @@ "name": "clet", "version": "1.0.1", "description": "Command Line E2E Testing", - "type": "module", - "main": "./lib/runner.js", - "exports": "./lib/runner.js", + "type": "commonjs", + "main": "./dist/index.js", "types": "./lib/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "author": "TZ (https://github.com/atian25)", "homepage": "https://github.com/node-modules/clet", "repository": "git@github.com:node-modules/clet.git", + "scripts": { + "lint": "eslint . --ext .ts", + "postlint": "tsc --noEmit", + "test": "mocha", + "cov": "c8 -n src/ -r html -r text npm test", + "ci": "npm run cov", + "tsc": "rm -rf dist && tsc", + "prepack": "npm run tsc" + }, + "files": [ + "dist" + ], "dependencies": { + "consola": "^2.15.3", "dirname-filename-esm": "^1.1.1", "dot-prop": "^7.2.0", - "execa": "^6.1.0", + "execa": "^5", + "extract-stack": "^2", "lodash.ismatch": "^4.4.0", - "p-event": "^5.0.1", - "strip-ansi": "^7.0.1", - "strip-final-newline": "^3.0.0", + "p-event": "^4", + "strip-ansi": "^6", + "strip-final-newline": "^2", "throwback": "^4.1.0", - "trash": "^8.1.0" + "trash": "^7" }, "devDependencies": { - "@vitest/coverage-c8": "^0.22.1", - "@vitest/ui": "^0.22.1", + "@artus/eslint-config-artus": "^0.0.1", + "@artus/tsconfig": "^1", + "@types/mocha": "^9.1.1", + "@types/node": "^18.7.14", + "c8": "^7.12.0", "cross-env": "^7.0.3", - "egg-ci": "^1.19.0", "enquirer": "^2.3.6", "eslint": "^7", "eslint-config-egg": "^9", + "mocha": "^10.0.0", "supertest": "^6.2.3", - "vitest": "^0.22.1" - }, - "files": [ - "bin", - "lib", - "index.js" - ], - "scripts": { - "lint": "eslint .", - "test": "vitest", - "cov": "vitest run --coverage", - "ci": "npm run lint && npm run cov" - }, - "ci": { - "version": "14, 16, 18", - "type": "github", - "npminstall": false - }, - "eslintConfig": { - "extends": "eslint-config-egg", - "root": true, - "env": { - "node": true, - "browser": false, - "jest": true - }, - "rules": { - "node/file-extension-in-import": [ - "error", - "always" - ] - }, - "parserOptions": { - "sourceType": "module" - }, - "ignorePatterns": [ - "dist", - "coverage", - "node_modules" - ] + "ts-mocha": "^10.0.0", + "ts-node": "^10.9.1", + "tslib": "^2.4.0", + "typescript": "^4.8.2" }, "license": "MIT" } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..70d268c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +import * as validator from './plugins/validator'; +import * as operation from './plugins/operation'; +import { TestRunner, PluginLike } from './runner'; + +export * from './runner'; +export * as assert from './lib/assert'; + +export function runner() { + return new TestRunner() + .plugin({ ...validator, ...operation } satisfies PluginLike); +} diff --git a/src/lib/assert.ts b/src/lib/assert.ts new file mode 100644 index 0000000..9ab66a8 --- /dev/null +++ b/src/lib/assert.ts @@ -0,0 +1,123 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import { match, doesNotMatch, AssertionError } from 'node:assert/strict'; + +import isMatch from 'lodash.ismatch'; +import { types, exists } from './utils'; + +type Actual = string | Record; +type Expected = string | RegExp | Record; + +/** + * assert the `actual` is match `expected` + * - when `expected` is regexp, detect by `RegExp.test` + * - when `expected` is json, detect by `lodash.ismatch` + * - when `expected` is string, detect by `String.includes` + */ +export function matchRule(actual: Actual, expected: Expected) { + if (types.isRegExp(expected)) { + match(actual.toString(), expected); + } else if (types.isObject(expected)) { + // if pattern is `json`, then convert actual to json and check whether contains pattern + const content = types.isString(actual) ? JSON.parse(actual) : actual; + const result = isMatch(content, expected); + if (!result) { + // print diff + throw new AssertionError({ + operator: 'should partial includes', + actual: content, + expected, + stackStartFn: matchRule, + }); + } + } else if (actual === undefined || !actual.includes(expected)) { + throw new AssertionError({ + operator: 'should includes', + actual, + expected, + stackStartFn: matchRule, + }); + } +} + +/** + * assert the `actual` is not match `expected` + * - when `expected` is regexp, detect by `RegExp.test` + * - when `expected` is json, detect by `lodash.ismatch` + * - when `expected` is string, detect by `String.includes` + */ +export function doesNotMatchRule(actual: Actual, expected: Expected) { + if (types.isRegExp(expected)) { + doesNotMatch(actual.toString(), expected); + } else if (types.isObject(expected)) { + // if pattern is `json`, then convert actual to json and check whether contains pattern + const content = types.isString(actual) ? JSON.parse(actual) : actual; + const result = isMatch(content, expected); + if (result) { + // print diff + throw new AssertionError({ + operator: 'should not partial includes', + actual: content, + expected, + stackStartFn: doesNotMatchRule, + }); + } + } else if (actual === undefined || actual.includes(expected)) { + throw new AssertionError({ + operator: 'should not includes', + actual, + expected, + stackStartFn: doesNotMatchRule, + }); + } +} + +/** + * validate file + * + * - `matchFile('/path/to/file')`: check whether file exists + * - `matchFile('/path/to/file', /\w+/)`: check whether file match regexp + * - `matchFile('/path/to/file', 'usage')`: check whether file includes specified string + * - `matchFile('/path/to/file', { version: '1.0.0' })`: checke whether file content partial includes specified JSON + */ +export async function matchFile(filePath: string, expected?: Expected) { + // check whether file exists + const isExists = await exists(filePath); + assert(isExists, `Expected ${filePath} to be exists`); + + // compare content, support string/json/regex + if (expected) { + const content = await fs.readFile(filePath, 'utf-8'); + try { + matchRule(content, expected); + } catch (err) { + err.message = `file(${filePath}) with content: ${err.message}`; + throw err; + } + } +} + +/** + * validate file with opposite rule + * + * - `doesNotMatchFile('/path/to/file')`: check whether file don't exists + * - `doesNotMatchFile('/path/to/file', /\w+/)`: check whether file don't match regex + * - `doesNotMatchFile('/path/to/file', 'usage')`: check whether file don't includes specified string + * - `doesNotMatchFile('/path/to/file', { version: '1.0.0' })`: checke whether file content don't partial includes specified JSON + */ +export async function doesNotMatchFile(filePath: string, expected?: Expected) { + // check whether file exists + const isExists = await exists(filePath); + if (!expected) { + assert(!isExists, `Expected ${filePath} to not be exists`); + } else { + assert(isExists, `Expected file(${filePath}) not to match \`${expected}\` but file not exists`); + const content = await fs.readFile(filePath, 'utf-8'); + try { + doesNotMatchRule(content, expected); + } catch (err) { + err.message = `file(${filePath}) with content: ${err.message}`; + throw err; + } + } +} diff --git a/src/lib/constant.ts b/src/lib/constant.ts new file mode 100644 index 0000000..77321ae --- /dev/null +++ b/src/lib/constant.ts @@ -0,0 +1,10 @@ +import { EOL } from 'os'; + +export const KEYS = { + UP: '\u001b[A', + DOWN: '\u001b[B', + LEFT: '\u001b[D', + RIGHT: '\u001b[C', + ENTER: EOL, + SPACE: ' ', +}; diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..e2ded8f --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,112 @@ +// import assert from 'node:assert/strict'; +// import util from 'node:util'; + +// export enum LogLevel { +// ERROR = 0, +// WARN = 1, +// LOG = 2, +// INFO = 3, +// DEBUG = 4, +// TRACE = 5, +// Silent = -Infinity, +// Verbose = Infinity, +// } + +// export interface LoggerOptions { +// level?: LogLevel; +// tag?: string | string[]; +// showTag?: boolean; +// showTime?: boolean; +// indent?: number; +// } + +// type LogMethods = { +// [key in Lowercase]: (message: any, ...args: any[]) => void; +// }; + +// export interface Logger extends LogMethods { } + +// export class Logger { +// private options: LoggerOptions; +// private childMaps: Record; + +// // Declare the type of the dynamically-registered methods +// // [key in Lowercase]: (message: any, ...args: any[]) => void; + +// constructor(tag?: string | LoggerOptions, opts: LoggerOptions = {}) { +// if (typeof tag === 'string') { +// opts.tag = opts.tag || tag || ''; +// } else { +// opts = tag; +// } +// opts.tag = [].concat(opts.tag || []); + +// this.options = { +// level: LogLevel.INFO, +// indent: 0, +// showTag: true, +// showTime: false, +// ...opts, +// }; + +// this.childMaps = {}; + +// // register methods +// for (const [ key, value ] of Object.entries(LogLevel)) { +// const fnName = key.toLowerCase(); +// const fn = console[fnName] || console.debug; +// this[fnName] = (message: any, ...args: any[]) => { +// if (value > this.options.level) return; +// const msg = this.format(message, args, this.options); +// return fn(msg); +// }; +// } + +// return this as unknown as typeof Logger.prototype & LogMethods; +// } + +// format(message: any, args: any[], options?: LoggerOptions) { +// const time = options.showTime ? `[${formatTime(new Date())}] ` : ''; +// const tag = options.showTag && options.tag.length ? `[${options.tag.join(':')}] ` : ''; +// const indent = ' '.repeat(options.indent); +// const prefix = time + indent + tag; +// const content = util.format(message, ...args).replace(/^/gm, prefix); +// return content; +// } + +// get level() { +// return this.options.level; +// } + +// set level(v: number | string) { +// this.options.level = normalize(v); +// } + +// child(tag: string, opts?: LoggerOptions) { +// assert(tag, 'tag is required'); +// if (!this.childMaps[tag]) { +// this.childMaps[tag] = new Logger({ +// ...this.options, +// indent: this.options.indent + 2, +// ...opts, +// tag: [ ...this.options.tag, tag ], +// }); +// } +// return this.childMaps[tag]; +// } +// } + +// function normalize(level: number | string) { +// if (typeof level === 'number') return level; +// const levelNum = LogLevel[level.toUpperCase()]; +// assert(levelNum, `unknown loglevel ${level}`); +// return levelNum; +// } + +// function formatTime(date: Date) { +// date = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)); +// return date.toISOString() +// .replace('T', ' ') +// .replace(/\..+$/, ''); +// } + diff --git a/src/lib/process.ts b/src/lib/process.ts new file mode 100644 index 0000000..1dfd338 --- /dev/null +++ b/src/lib/process.ts @@ -0,0 +1,185 @@ +import EventEmitter from 'node:events'; +import { PassThrough } from 'node:stream'; +import { EOL } from 'node:os'; + +import * as execa from 'execa'; +import { NodeOptions, ExecaReturnValue, ExecaChildProcess } from 'execa'; +import pEvent from 'p-event'; +import stripFinalNewline from 'strip-final-newline'; +import stripAnsi from 'strip-ansi'; + +export type ProcessOptions = { + -readonly [ key in keyof NodeOptions ]: NodeOptions[key]; +} & { + execArgv?: NodeOptions['nodeOptions']; +}; + +export type ProcessResult = ExecaReturnValue; +export type ProcessEvents = 'stdout' | 'stderr' | 'message' | 'exit' | 'close'; + +export class Process extends EventEmitter { + type: 'fork' | 'spawn'; + cmd: string; + args: string[]; + opts: ProcessOptions; + proc: ExecaChildProcess; + result: ProcessResult; + + constructor(type: Process['type'], cmd: string, args: string[] = [], opts: ProcessOptions = {}) { + super(); + // assert(!this.cmd, 'cmd can not be registered twice'); + + this.type = type; + this.cmd = cmd; + this.args = args; + // this.cwd = opts?.cwd || process.cwd(); + // this.env = opts?.env || process.env; + + const { execArgv, nodeOptions, ...restOpts } = opts; + // TODO: execArgv nodeOptions only allow once and in fork mode + + this.opts = { + reject: false, + cwd: process.cwd(), + nodeOptions: execArgv || nodeOptions, + input: new PassThrough(), + preferLocal: true, + ...restOpts, + }; + + // stdout stderr use passthrough, so don't need to on event and recollect + // need to test color + + this.result = { + stdout: '', + stderr: '', + } as any; + } + + write(data: string) { + // FIXME: when stdin.write, stdout will recieve duplicate output + // auto add \n + this.proc.stdin!.write(data.replace(/\r?\n$/, '') + EOL); + // (this.opts.input as PassThrough).write(data); + // (this.opts.stdin as Readable).write(data); + + // hook rl event to find whether prompt is trigger? + } + + env(key: string, value: string) { + this.opts.env![key] = value; + } + + cwd(cwd: string) { + this.opts.cwd = cwd; + } + + async start() { + if (this.type === 'fork') { + this.proc = execa.node(this.cmd, this.args, this.opts); + } else { + const cmdString = [ this.cmd, ...this.args ].join(' '); + this.proc = execa.command(cmdString, this.opts); + } + + this.proc.then(res => { + this.result = res; + this.result.stdout = stripAnsi(this.result.stdout); + this.result.stderr = stripAnsi(this.result.stderr); + + if (res instanceof Error) { + // when spawn not exist, code is ENOENT + const { code, message } = res as any; + if (code === 'ENOENT') { + this.result.exitCode = 127; + this.result.stderr += message; + } + } + }); + + this.proc.stdout!.on('data', data => { + const origin = stripFinalNewline(data.toString()); + const content = stripAnsi(origin); + this.result.stdout += content; + this.emit('stdout', origin); + }); + + this.proc.stderr!.on('data', data => { + const origin = stripFinalNewline(data.toString()); + const content = stripAnsi(origin); + this.result.stderr += content; + this.emit('stderr', origin); + }); + + this.proc.on('message', data => { + this.emit('message', data); + // console.log('message event:', data); + }); + + // this.proc.once('exit', code => { + // this.result.exitCode = code; + // // console.log('close event:', code); + // }); + + // this.proc.on('error', err => { + // console.log('@@error', err); + // }); + + // this.proc.once('close', code => { + // // this.emit('close', code); + // this.result.code = code; + // // console.log('close event:', code); + // }); + // return this.proc; + return this; + } + + async end() { + return this.proc; + } + + kill(signal?: string) { + // TODO: kill process use cancel()? + this.proc.kill(signal); + } + + // stdin -> wait(stdout) -> write + async wait(type: ProcessEvents, expected) { + let promise; + switch (type) { + case 'stdout': + case 'stderr': { + promise = pEvent(this.proc[type] as any, 'data', { + rejectionEvents: ['close'], + filter: () => { + return expected.test(this.result[type]); + }, + }); // .then(() => this.result[type]); + break; + } + + + case 'message': { + promise = pEvent(this.proc, 'message', { + rejectionEvents: ['close'], + // filter: input => utils.validate(input, expected), + }); + break; + } + + case 'exit': { + promise = pEvent(this.proc, 'exit'); + break; + } + + case 'close': + default: { + promise = pEvent(this.proc, 'close'); + break; + } + } + return promise; + } +} + +// use readable stream to write stdin diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..7577b19 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,9 @@ +export type MountPlugin = { + [key in keyof T]: T[key] extends (core: Core, ...args: infer I) => any ? (...args: I) => MountPlugin : T[key]; +} & Core; + +export type RestParam = T extends (first: any, ...args: infer R) => any ? R : any; + +export interface PluginLike { + [key: string]: (core: any, ...args: any[]) => any; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..8fb07cd --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,175 @@ +import fs from 'node:fs/promises'; +import util from 'node:util'; +import path from 'node:path'; +import { EOL } from 'node:os'; +import tty from 'node:tty'; + +import isMatch from 'lodash.ismatch'; +import trash from 'trash'; + +const types = { + ...util.types, + isString(v: any): v is string { + return typeof v === 'string'; + }, + isObject(v: any): v is object { + return v !== null && typeof v === 'object'; + }, + isFunction(v: any): v is (...args: any[]) => any { + return typeof v === 'function'; + }, +}; + +export { types, isMatch }; + +const extractPathRegex = /\s+at.*[(\s](.*):\d+:\d+\)?/; +const testFileRegex = /\.(test|spec)\.(ts|mts|cts|js|cjs|mjs)$/; + +export function wrapFn any>(fn: T): T { + let testFile; + const buildError = new Error('only for stack'); + Error.captureStackTrace(buildError, wrapFn); + const additionalStack = buildError.stack! + .split(EOL) + .filter(line => { + const [, file] = line.match(extractPathRegex) || []; + if (!file || testFile) return false; + if (file.match(testFileRegex)) { + testFile = file; + } + return true; + }) + .reverse() + .slice(0, 10) + .join(EOL); + + const wrappedFn = async function (...args: Parameters) { + try { + return await fn(...args); + } catch (err) { + const index = err.stack!.indexOf(' at '); + const lineEndIndex = err.stack!.indexOf(EOL, index); + const line = err.stack!.slice(index, lineEndIndex); + if (!line.includes(testFile)) { + err.stack = err.stack!.slice(0, index) + additionalStack + EOL + err.stack.slice(index); + } + err.cause = buildError; + throw err; + } + }; + + return wrappedFn as T; +} + +/** + * validate input with expected rules + * + * @param {string|object} input - target + * @param {string|regexp|object|function|array} expected - rules + * @return {boolean} pass or not + */ +export function validate(input, expected) { + if (Array.isArray(expected)) { + return expected.some(rule => validate(input, rule)); + } else if (types.isRegExp(expected)) { + return expected.test(input); + } else if (types.isString(expected)) { + return input && input.includes(expected); + } else if (types.isObject(expected)) { + return isMatch(input, expected); + } + return expected(input); +} + +/** + * Check whether is parent + * + * @param {string} parent - parent file path + * @param {string} child - child file path + * @return {boolean} true if parent >= child + */ +export function isParent(parent: string, child: string): boolean { + const p = path.relative(parent, child); + return !(p === '' || p.startsWith('..')); +} + +/** + * mkdirp -p + * + * @param {string} dir - dir path + * @param {object} [opts] - see fsPromises.mkdirp + */ +export async function mkdir(dir: string, opts?: any) { + return await fs.mkdir(dir, { recursive: true, ...opts }); +} + +/** + * removes files and directories. + * + * by default it will only moves them to the trash, which is much safer and reversible. + * + * @param {string|string[]} p - accepts paths and [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns) + * @param {object} [opts] - options of [trash](https://github.com/sindresorhus/trash) or [fsPromises.rm](https://nodejs.org/api/fs.html#fs_fspromises_rm_path_options) + * @param {boolean} [opts.trash=true] - whether to move to [trash](https://github.com/sindresorhus/trash) or permanently delete + */ +export async function rm(p, opts = {}) { + /* istanbul ignore if */ + // if (opts.trash === false || process.env.CI) { + // return await fs.rm(p, { force: true, recursive: true, ...opts }); + // } + /* istanbul ignore next */ + return await trash(p, opts); +} + + +/** + * write file, will auto create parent dir + * + * @param {string} filePath - file path + * @param {string|object} content - content to write, if pass object, will `JSON.stringify` + * @param {object} [opts] - see fsPromises.writeFile + */ +export async function writeFile(filePath: string, content: string | Record, opts?: any) { + await mkdir(path.dirname(filePath)); + if (types.isObject(content)) { + content = JSON.stringify(content, null, 2); + } + return await fs.writeFile(filePath, content, opts); +} + +/** + * check exists due to `fs.exists` is deprecated + */ +export async function exists(filePath: string) { + return await fs.access(filePath).then(() => true).catch(() => false); +} + +/** + * resolve file path by import.meta, kind of __dirname for esm + * + * @param {Object} meta - import.meta + * @param {...String} args - other paths + * @return {String} file path + */ +// export function resolve(meta, ...args) { +// // const p = types.isObject(meta) ? dirname(meta) : meta; +// // return path.resolve(p, ...args); +// } + +/** + * take a sleep + * + * @param {number} ms - millisecond + */ +export async function sleep(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +export function color(str: string, startCode: number, endCode: number) { + // https://github.com/sindresorhus/yoctocolors + const hasColors = tty.WriteStream.prototype.hasColors(); + if (!hasColors) return str; + return '\u001B[' + startCode + 'm' + str + '\u001B[' + endCode + 'm'; +} diff --git a/src/plugins/operation.ts b/src/plugins/operation.ts new file mode 100644 index 0000000..48a87f2 --- /dev/null +++ b/src/plugins/operation.ts @@ -0,0 +1,7 @@ +import type { TestRunner, HookFunction } from '../runner'; + +export function tap1(runner: TestRunner, fn: HookFunction) { + return runner.hook('after', async ctx => { + await fn.call(runner, ctx); + }); +} diff --git a/src/plugins/validator.ts b/src/plugins/validator.ts new file mode 100644 index 0000000..576b242 --- /dev/null +++ b/src/plugins/validator.ts @@ -0,0 +1,59 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import type { TestRunner } from '../runner'; +import { matchRule, doesNotMatchRule, matchFile, doesNotMatchFile } from '../lib/assert'; + +export function stdout(runner: TestRunner, expected: string | RegExp) { + return runner.expect(async ctx => { + matchRule(ctx.result.stdout, expected); + }); +} + +export function notStdout(runner: TestRunner, expected: string | RegExp) { + return runner.expect(async ctx => { + doesNotMatchRule(ctx.result.stdout, expected); + }); +} + +export function stderr(runner: TestRunner, expected: string | RegExp) { + return runner.expect(async ctx => { + matchRule(ctx.result.stderr, expected); + }); +} + +export function notStderr(runner: TestRunner, expected: string | RegExp) { + return runner.expect(async ({ result }) => { + doesNotMatchRule(result.stderr, expected); + }); +} + +export function file(runner: TestRunner, filePath: string, expected: string | RegExp) { + return runner.expect(async ({ cwd }) => { + const fullPath = path.resolve(cwd, filePath); + await matchFile(fullPath, expected); + }); +} + +export function notFile(runner: TestRunner, filePath: string, expected: string | RegExp) { + return runner.expect(async ({ cwd }) => { + const fullPath = path.resolve(cwd, filePath); + await doesNotMatchFile(fullPath, expected); + }); +} + +export function code(runner: TestRunner, expected: number) { + runner.expect(async ctx => { + ctx.autoCheckCode = false; + // when using `.wait()`, it could maybe not exit at this time, so skip and will double check it later + if (ctx.result.exitCode !== undefined) { + assert.equal(ctx.result.exitCode, expected); + } + }); + + // double check + runner.hook('end', async ctx => { + assert.equal(ctx.result.exitCode, expected); + }); + + return runner; +} diff --git a/src/runner.ts b/src/runner.ts new file mode 100644 index 0000000..2951a06 --- /dev/null +++ b/src/runner.ts @@ -0,0 +1,186 @@ +import EventEmitter from 'events'; +import assert from 'node:assert/strict'; +import logger from 'consola'; + +import { Process, ProcessEvents, ProcessOptions, ProcessResult } from './lib/process'; +import { wrapFn, color } from './lib/utils'; +// import { doesNotMatchRule, matchRule, matchFile, doesNotMatchFile } from './lib/assert'; + +export type HookFunction = (ctx: RunnerContext) => void | Promise; +export type RestParam = T extends (first: any, ...args: infer R) => any ? R : any; + +export type MountPlugin = { + [P in keyof T]: T[P] extends (core: TestRunner, ...args: infer I) => any ? (...args: I) => MountPlugin : undefined; +} & TestRunner; + +// use `satisfies` +export interface PluginLike { + [key: string]: (core: TestRunner, ...args: any[]) => any; +} + +export type HookEventType = 'before' | 'running' | 'after' | 'end' | 'error'; + +export interface RunnerContext { + proc: Process; + cwd: string; + result: ProcessResult; + autoWait?: boolean; + autoCheckCode?: boolean; + debug?: boolean; +} + +export class TestRunner extends EventEmitter { + private logger = logger; + private proc: Process; + private hooks: Record = { + before: [], + running: [], + after: [], + end: [], + error: [], + }; + + plugin(plugins: T) { + for (const key of Object.keys(plugins)) { + const initFn = plugins[key]; + + this[key] = (...args: RestParam) => { + initFn(this, ...args); + return this; + }; + } + return this as MountPlugin; + } + + hook(event: HookEventType, fn: HookFunction) { + this.hooks[event].push(wrapFn(fn)); + return this; + } + + async end() { + try { + const ctx: RunnerContext = { + proc: this.proc, + cwd: this.proc.opts.cwd!, + // use getter + get result() { + return this.proc.result; + }, + autoWait: true, + autoCheckCode: true, + }; + + assert(this.proc, 'cmd is not registered yet'); + + // before + for (const fn of this.hooks['before']) { + await fn(ctx); + } + + // exec child process, don't await it + await this.proc.start(); + + this.proc.on('stdout', data => { + console.log(color(data, 2, 22)); + }); + + this.proc.on('stderr', data => { + console.error(color(data, 2, 22)); + }); + + // running + for (const fn of this.hooks['running']) { + await fn(ctx); + } + + if (ctx.autoWait) { + await this.proc.end(); + } + + // after + for (const fn of this.hooks['after']) { + await fn(ctx); + } + + // ensure proc is exit if user forgot to call `wait('close')` after wait other event + if (!ctx.autoWait) { + await this.proc.end(); + } + + // error + for (const fn of this.hooks['error']) { + if (ctx.result instanceof Error) { + await fn(ctx); + } + } + + // end + for (const fn of this.hooks['end']) { + await fn(ctx); + } + + // if developer don't call `.code()`, will rethrow proc error in order to avoid omissions + if (ctx.autoCheckCode) { + // `killed` is true only if call `kill()/cancel()` manually + const { failed, isCanceled, killed } = ctx.result; + if (failed && !isCanceled && !killed) throw ctx.result; + } + + this.logger.success('Test pass.\n'); + } catch (err) { + this.logger.error('Test failed.\n'); + throw err; + } finally { + // clean up + this.proc.kill(); + } + } + + fork(cmd: string, args?: string[], opts?: ProcessOptions) { + assert(!this.proc, 'cmd can not be registered twice'); + this.proc = new Process('fork', cmd, args, opts); + return this; + } + + spawn(cmd: string, args?: string[], opts?: ProcessOptions) { + assert(!this.proc, 'cmd can not be registered twice'); + this.proc = new Process('spawn', cmd, args, opts); + return this; + } + + debug(enabled = true) { + return this.hook('before', ctx => { + ctx.debug = enabled; + // this.proc.debug(enabled); + }); + } + + tap(fn: HookFunction) { + return this.hook(this.proc ? 'after' : 'before', fn); + } + + expect(fn: HookFunction) { + return this.tap(async ctx => { + // TODO: wrapfn here + await fn.call(this, ctx); + }); + } + + wait(type: ProcessEvents, expected) { + // prevent auto wait + this.hook('before', ctx => { + ctx.autoWait = false; + }); + + // wait for process ready then assert + this.hook('running', async ({ proc }) => { + await proc.wait(type, expected); + }); + + return this; + } + + // stdin() { + // // return this.proc.stdin(); + // } +} diff --git a/test/command.test.js b/test-old/command.test.js similarity index 100% rename from test/command.test.js rename to test-old/command.test.js diff --git a/test/commonjs.test.cjs b/test-old/commonjs.test.cjs similarity index 100% rename from test/commonjs.test.cjs rename to test-old/commonjs.test.cjs diff --git a/test/example.test.js b/test-old/example.test.js similarity index 100% rename from test/example.test.js rename to test-old/example.test.js diff --git a/test/file.test.js b/test-old/file.test.js similarity index 100% rename from test/file.test.js rename to test-old/file.test.js diff --git a/test/fixtures/command/bin/cli.js b/test-old/fixtures/command/bin/cli.js similarity index 100% rename from test/fixtures/command/bin/cli.js rename to test-old/fixtures/command/bin/cli.js diff --git a/test/fixtures/command/package.json b/test-old/fixtures/command/package.json similarity index 100% rename from test/fixtures/command/package.json rename to test-old/fixtures/command/package.json diff --git a/test/fixtures/example/bin/cli.js b/test-old/fixtures/example/bin/cli.js similarity index 100% rename from test/fixtures/example/bin/cli.js rename to test-old/fixtures/example/bin/cli.js diff --git a/test/fixtures/example/package.json b/test-old/fixtures/example/package.json similarity index 100% rename from test/fixtures/example/package.json rename to test-old/fixtures/example/package.json diff --git a/test/fixtures/file.js b/test-old/fixtures/file.js similarity index 100% rename from test/fixtures/file.js rename to test-old/fixtures/file.js diff --git a/test-old/fixtures/file/test.json b/test-old/fixtures/file/test.json new file mode 100644 index 0000000..54f9bac --- /dev/null +++ b/test-old/fixtures/file/test.json @@ -0,0 +1,7 @@ +{ + "name": "test", + "version": "1.0.0", + "config": { + "port": 8080 + } +} \ No newline at end of file diff --git a/test-old/fixtures/file/test.md b/test-old/fixtures/file/test.md new file mode 100644 index 0000000..73a2e53 --- /dev/null +++ b/test-old/fixtures/file/test.md @@ -0,0 +1,2 @@ +# test +this is a README \ No newline at end of file diff --git a/test/fixtures/logger.js b/test-old/fixtures/logger.js similarity index 100% rename from test/fixtures/logger.js rename to test-old/fixtures/logger.js diff --git a/test/fixtures/long-run.js b/test-old/fixtures/long-run.js similarity index 100% rename from test/fixtures/long-run.js rename to test-old/fixtures/long-run.js diff --git a/test/fixtures/middleware.js b/test-old/fixtures/middleware.js similarity index 100% rename from test/fixtures/middleware.js rename to test-old/fixtures/middleware.js diff --git a/test/fixtures/process.js b/test-old/fixtures/process.js similarity index 100% rename from test/fixtures/process.js rename to test-old/fixtures/process.js diff --git a/test/fixtures/prompt.js b/test-old/fixtures/prompt.js similarity index 100% rename from test/fixtures/prompt.js rename to test-old/fixtures/prompt.js diff --git a/test/fixtures/readline.js b/test-old/fixtures/readline.js similarity index 100% rename from test/fixtures/readline.js rename to test-old/fixtures/readline.js diff --git a/test/fixtures/server/bin/cli.js b/test-old/fixtures/server/bin/cli.js similarity index 100% rename from test/fixtures/server/bin/cli.js rename to test-old/fixtures/server/bin/cli.js diff --git a/test/fixtures/server/index.js b/test-old/fixtures/server/index.js similarity index 100% rename from test/fixtures/server/index.js rename to test-old/fixtures/server/index.js diff --git a/test/fixtures/server/package.json b/test-old/fixtures/server/package.json similarity index 100% rename from test/fixtures/server/package.json rename to test-old/fixtures/server/package.json diff --git a/test/fixtures/version.js b/test-old/fixtures/version.js similarity index 100% rename from test/fixtures/version.js rename to test-old/fixtures/version.js diff --git a/test/fixtures/wait.js b/test-old/fixtures/wait.js similarity index 100% rename from test/fixtures/wait.js rename to test-old/fixtures/wait.js diff --git a/test/middleware.test.js b/test-old/middleware.test.js similarity index 100% rename from test/middleware.test.js rename to test-old/middleware.test.js diff --git a/test/operation.test.js b/test-old/operation.test.js similarity index 100% rename from test/operation.test.js rename to test-old/operation.test.js diff --git a/test/plugin.test.js b/test-old/plugin.test.js similarity index 100% rename from test/plugin.test.js rename to test-old/plugin.test.js diff --git a/test/process.test.js b/test-old/process.test.js similarity index 100% rename from test/process.test.js rename to test-old/process.test.js diff --git a/test/prompt.test.js b/test-old/prompt.test.js similarity index 100% rename from test/prompt.test.js rename to test-old/prompt.test.js diff --git a/test/runner.test.js b/test-old/runner.test.js similarity index 100% rename from test/runner.test.js rename to test-old/runner.test.js diff --git a/test/setup.js b/test-old/setup.js similarity index 100% rename from test/setup.js rename to test-old/setup.js diff --git a/test/stack.test.js b/test-old/stack.test.js similarity index 100% rename from test/stack.test.js rename to test-old/stack.test.js diff --git a/test/test-utils.js b/test-old/test-utils.ts similarity index 100% rename from test/test-utils.js rename to test-old/test-utils.ts diff --git a/test/wait.test.js b/test-old/wait.test.js similarity index 100% rename from test/wait.test.js rename to test-old/wait.test.js diff --git a/test/fixtures/process/color.ts b/test/fixtures/process/color.ts new file mode 100644 index 0000000..27fdcfa --- /dev/null +++ b/test/fixtures/process/color.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +const cli = '\x1b[37;1m' + 'CLI' + '\x1b[0m'; +const hub = '\x1b[43;1m' + 'Hub' + '\x1b[0m'; +const msg = '\x1b[37;1m' + 'MSG' + '\x1b[0m'; + +console.log(cli + hub); +console.warn(msg + hub); diff --git a/test/fixtures/process/error.ts b/test/fixtures/process/error.ts new file mode 100644 index 0000000..e4a0bc4 --- /dev/null +++ b/test/fixtures/process/error.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +console.log('this is an error test'); +throw new Error('some error'); diff --git a/test/fixtures/process/fork.ts b/test/fixtures/process/fork.ts new file mode 100644 index 0000000..8e5be4c --- /dev/null +++ b/test/fixtures/process/fork.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +console.log(process.version); +console.warn('this is testing'); diff --git a/test/fixtures/process/long-run.ts b/test/fixtures/process/long-run.ts new file mode 100644 index 0000000..ae718eb --- /dev/null +++ b/test/fixtures/process/long-run.ts @@ -0,0 +1,27 @@ +import http from 'node:http'; + +const port = process.env.PORT || 3000; + +const app = http.createServer((req, res) => { + console.log(`Receive: ${req.url}`); + + if (req.url === '/exit') { + res.end('byebye'); + process.exit(0); + } else { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World'); + } +}); + +app.listen(port, () => { + console.log(`Server running at http://localhost:${port}/`); + if (process.send) { + process.send('ready'); + } + + setTimeout(() => { + console.log('timeout'); + process.exit(0); + }, 5000); +}); diff --git a/test/fixtures/process/message.ts b/test/fixtures/process/message.ts new file mode 100644 index 0000000..b88b595 --- /dev/null +++ b/test/fixtures/process/message.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +console.log('starting...'); + +setTimeout(() => { + if (process.send) { + process.send('ready'); + } +}, 500); diff --git a/test/fixtures/process/prompt.ts b/test/fixtures/process/prompt.ts new file mode 100644 index 0000000..60c4462 --- /dev/null +++ b/test/fixtures/process/prompt.ts @@ -0,0 +1,29 @@ +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; + +const rl = readline.createInterface({ input, output }); + +rl.on('pause', () => { + console.log('Readline paused.'); +}); + +rl.on('resume', () => { + console.log('Readline resumed.'); +}); + +rl.question('What is your favorite food? ', (answer) => { + console.log(`Oh, so your favorite food is ${answer}`); + let i = 0; + + rl.close(); + // const id = setInterval(() => { + // i++; + // console.log(`#${i}.`, new Date()); + // }, 1000); + + // setTimeout(() => { + // console.error('some error'); + // console.log('end'); + // clearInterval(id); + // }, 1000 * 10); +}); diff --git a/test/assert.test.js b/test/lib/assert.test.ts similarity index 73% rename from test/assert.test.js rename to test/lib/assert.test.ts index 1c56fa4..4352740 100644 --- a/test/assert.test.js +++ b/test/lib/assert.test.ts @@ -1,9 +1,9 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; -import path from 'path'; -import { it, describe } from 'vitest'; -import { assert, matchRule, doesNotMatchRule } from '../lib/assert.js'; +import { matchRule, doesNotMatchRule, matchFile, doesNotMatchFile } from '../../src/lib/assert'; -describe('test/assert.test.js', () => { +describe('test/lib/assert.test.ts', () => { const pkgInfo = { name: 'clet', version: '1.0.0', @@ -12,18 +12,12 @@ describe('test/assert.test.js', () => { }, }; - it('should export', () => { - assert.equal(assert.matchRule, matchRule); - assert.equal(assert.doesNotMatchRule, doesNotMatchRule); - }); - describe('matchRule', () => { it('should support regexp', () => { - matchRule(123456, /\d+/); matchRule('abc', /\w+/); assert.throws(() => { - matchRule(123456, /abc/); + matchRule('123456', /abc/); }, { name: 'AssertionError', message: /The input did not match the regular expression/, @@ -72,11 +66,10 @@ describe('test/assert.test.js', () => { describe('doesNotMatchRule', () => { it('should support regexp', () => { - doesNotMatchRule(123456, /abc/); doesNotMatchRule('abc', /\d+/); assert.throws(() => { - doesNotMatchRule(123456, /\d+/); + doesNotMatchRule('123456', /\d+/); }, { name: 'AssertionError', message: /The input was expected to not match the regular expression/, @@ -125,19 +118,19 @@ describe('test/assert.test.js', () => { describe('matchFile', () => { const fixtures = path.resolve('test/fixtures/file'); it('should check exists', async () => { - await assert.matchFile(`${fixtures}/test.md`); + await matchFile(`${fixtures}/test.md`); await assert.rejects(async () => { - await assert.matchFile(`${fixtures}/not-exist.md`); + await matchFile(`${fixtures}/not-exist.md`); }, /not-exist.md to be exists/); }); it('should check content', async () => { - await assert.matchFile(`${fixtures}/test.md`, 'this is a README'); - await assert.matchFile(`${fixtures}/test.md`, /this is a README/); - await assert.matchFile(`${fixtures}/test.json`, { name: 'test', config: { port: 8080 } }); + await matchFile(`${fixtures}/test.md`, 'this is a README'); + await matchFile(`${fixtures}/test.md`, /this is a README/); + await matchFile(`${fixtures}/test.json`, { name: 'test', config: { port: 8080 } }); await assert.rejects(async () => { - await assert.matchFile(`${fixtures}/test.md`, 'abc'); + await matchFile(`${fixtures}/test.md`, 'abc'); }, /file.*test\.md.*this is.*should includes 'abc'/); }); }); @@ -145,23 +138,23 @@ describe('test/assert.test.js', () => { describe('doesNotMatchFile', () => { const fixtures = path.resolve('test/fixtures/file'); it('should check not exists', async () => { - await assert.doesNotMatchFile(`${fixtures}/a/b/c/d.md`); + await doesNotMatchFile(`${fixtures}/a/b/c/d.md`); await assert.rejects(async () => { - await assert.doesNotMatchFile(`${fixtures}/not-exist.md`, 'abc'); + await doesNotMatchFile(`${fixtures}/not-exist.md`, 'abc'); }, /Expected file\(.*not-exist.md\) not to match.*but file not exists/); }); it('should check not content', async () => { - await assert.doesNotMatchFile(`${fixtures}/test.md`, 'abc'); - await assert.doesNotMatchFile(`${fixtures}/test.md`, /abcccc/); - await assert.doesNotMatchFile(`${fixtures}/test.json`, { name: 'test', config: { a: 1 } }); + await doesNotMatchFile(`${fixtures}/test.md`, 'abc'); + await doesNotMatchFile(`${fixtures}/test.md`, /abcccc/); + await doesNotMatchFile(`${fixtures}/test.json`, { name: 'test', config: { a: 1 } }); await assert.rejects(async () => { - await assert.doesNotMatchFile(`${fixtures}/test.md`, 'this is a README'); + await doesNotMatchFile(`${fixtures}/test.md`, 'this is a README'); }, /file.*test\.md.*this is.*should not includes 'this is a README'/); }); }); - it.todo('error stack'); + // it.todo('error stack'); }); diff --git a/test/lib/process.test.ts b/test/lib/process.test.ts new file mode 100644 index 0000000..ab00821 --- /dev/null +++ b/test/lib/process.test.ts @@ -0,0 +1,107 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { Process } from '../../src/lib/process'; + +describe('test/process.test.ts', () => { + it('should merge options', () => { + const proc = new Process('fork', 'fixtures/process/fork.ts', ['--foo', 'bar'], { + nodeOptions: ['--inspect-brk'], + }); + + assert.strictEqual(proc.opts.cwd, process.cwd()); + assert.strictEqual(proc.opts.nodeOptions?.[0], '--inspect-brk'); + }); + + it('should spawn', async () => { + const proc = new Process('spawn', 'node', ['-p', 'process.version', '--inspect'], {}); + await proc.start(); + await proc.end(); + // console.log(proc.result); + assert.match(proc.result.stdout, /v\d+\.\d+\.\d+/); + assert.match(proc.result.stderr, /Debugger listening on/); + assert.strictEqual(proc.result.exitCode, 0); + }); + + it('should fork', async () => { + const cli = path.resolve(__dirname, '../fixtures/process/fork.ts'); + const proc = new Process('fork', cli); + await proc.start(); + await proc.end(); + // console.log(proc.result); + assert.match(proc.result.stdout, /v\d+\.\d+\.\d+/); + assert.match(proc.result.stderr, /this is testing/); + assert.strictEqual(proc.result.exitCode, 0); + }); + + it('should spawn not-exits', async () => { + const proc = new Process('spawn', 'not-exists'); + await proc.start(); + await proc.end(); + // console.log(proc.result); + assert.match(proc.result.stderr, /Command failed with ENOENT/); + assert.strictEqual(proc.result.exitCode, 127); + }); + + it('should fork not-exits', async () => { + const proc = new Process('fork', 'not-exists.ts'); + await proc.start(); + await proc.end(); + assert.match(proc.result.stderr, /Cannot find module/); + assert.strictEqual(proc.result.exitCode, 1); + }); + + it('should strip color', async () => { + const cli = path.resolve(__dirname, '../fixtures/process/color.ts'); + const proc = new Process('fork', cli, []); + await proc.start(); + await proc.end(); + // console.log(proc.result); + assert.match(proc.result.stdout, /CLIHub/); + assert.match(proc.result.stderr, /MSGHub/); + assert.strictEqual(proc.result.exitCode, 0); + }); + + it('should exit with fail', async () => { + const cli = path.resolve(__dirname, '../fixtures/process/error.ts'); + const proc = new Process('fork', cli, []); + await proc.start(); + await proc.end().catch(err => err); + // console.log(proc.result); + assert.match(proc.result.stdout, /this is an error test/); + assert.match(proc.result.stderr, /Error: some error/); + assert.strictEqual(proc.result.exitCode, 1); + }); + + it.skip('should env()'); + + it.skip('should cwd()'); + + it.skip('should write()'); + + it.skip('should kill()'); + + it.skip('should await()'); + + // it.skip('should execa work', async () => { + // const proc = execa.node('src/try/child.ts', ['--foo', 'bar'], { + // nodeOptions: [ '--require', 'ts-node/register' ], + // }); + + // proc.stdout?.on('data', data => { + // console.log('stdout', data.toString()); + // }); + // // proc.stdin.setEncoding('utf8'); + + // // const stdin = new PassThrough(); + // // stdin.pipe(proc.stdin); + + // setTimeout(() => { + // console.log('write stdin'); + // proc.stdin?.write('hello\n'); + // proc.stdin?.end(); + // // stdin.end(); + // }, 1500); + + // await proc; + // }); +}); diff --git a/test/logger.test.js b/test/logger.test.js deleted file mode 100644 index 87acb85..0000000 --- a/test/logger.test.js +++ /dev/null @@ -1,72 +0,0 @@ - -import { it, describe, beforeEach, afterEach, expect, vi } from 'vitest'; -import { Logger, LogLevel } from '../lib/logger.js'; - -describe('test/logger.test.js', () => { - beforeEach(() => { - for (const name of [ 'error', 'warn', 'info', 'log', 'debug' ]) { - vi.spyOn(global.console, name); - } - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should support level', () => { - const logger = new Logger(); - expect(logger.level === LogLevel.INFO); - - logger.level = LogLevel.DEBUG; - logger.debug('debug log'); - expect(console.debug).toHaveBeenCalledWith('debug log'); - - logger.level = 'WARN'; - logger.info('info log'); - expect(console.info).not.toHaveBeenCalled(); - }); - - it('should logger verbose', () => { - const logger = new Logger({ level: LogLevel.Verbose }); - logger.error('error log'); - logger.warn('warn log'); - logger.info('info log'); - logger.debug('debug log'); - logger.verbose('verbose log'); - - expect(console.error).toHaveBeenCalledWith('error log'); - expect(console.warn).toHaveBeenCalledWith('warn log'); - expect(console.info).toHaveBeenCalledWith('info log'); - expect(console.debug).toHaveBeenCalledWith('debug log'); - expect(console.debug).toHaveBeenCalledWith('verbose log'); - }); - - it('should logger as default', () => { - const logger = new Logger(); - logger.error('error log'); - logger.warn('warn log'); - logger.info('info log'); - logger.debug('debug log'); - logger.verbose('verbose log'); - - expect(console.error).toHaveBeenCalledWith('error log'); - expect(console.warn).toHaveBeenCalledWith('warn log'); - expect(console.info).toHaveBeenCalledWith('info log'); - expect(console.debug).not.toHaveBeenCalled(); - }); - - it('should support tag/time', () => { - const logger = new Logger({ tag: 'A', showTag: true, showTime: true }); - - logger.info('info log'); - expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\] \[A\] info log/)); - }); - - it('should child()', () => { - const logger = new Logger('A'); - const childLogger = logger.child('B', { showTag: true, showTime: true }); - expect(childLogger === logger.child('B')); - childLogger.warn('info log'); - expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\]\s{3}\[A:B\] info log/)); - }); -}); diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..88285d5 --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,71 @@ + +// import { Logger, LogLevel } from '../src/lib/logger'; + +// describe.skip('test/logger.test.js', () => { +// beforeEach(() => { +// for (const name of [ 'error', 'warn', 'info', 'log', 'debug' ] as const) { +// vi.spyOn(global.console, name); +// } +// }); + +// afterEach(() => { +// vi.resetAllMocks(); +// }); + +// it('should support level', () => { +// const logger = new Logger(); +// expect(logger.level === LogLevel.INFO); + +// logger.level = LogLevel.DEBUG; +// logger.debug('debug log'); +// expect(console.debug).toHaveBeenCalledWith('debug log'); + +// logger.level = 'WARN'; +// logger.info('info log'); +// expect(console.info).not.toHaveBeenCalled(); +// }); + +// it('should logger verbose', () => { +// const logger = new Logger({ level: LogLevel.Verbose }); +// logger.error('error log'); +// logger.warn('warn log'); +// logger.info('info log'); +// logger.debug('debug log'); +// logger.verbose('verbose log'); + +// expect(console.error).toHaveBeenCalledWith('error log'); +// expect(console.warn).toHaveBeenCalledWith('warn log'); +// expect(console.info).toHaveBeenCalledWith('info log'); +// expect(console.debug).toHaveBeenCalledWith('debug log'); +// expect(console.debug).toHaveBeenCalledWith('verbose log'); +// }); + +// it('should logger as default', () => { +// const logger = new Logger(); +// logger.error('error log'); +// logger.warn('warn log'); +// logger.info('info log'); +// logger.debug('debug log'); +// logger.verbose('verbose log'); + +// expect(console.error).toHaveBeenCalledWith('error log'); +// expect(console.warn).toHaveBeenCalledWith('warn log'); +// expect(console.info).toHaveBeenCalledWith('info log'); +// expect(console.debug).not.toHaveBeenCalled(); +// }); + +// it('should support tag/time', () => { +// const logger = new Logger({ tag: 'A', showTag: true, showTime: true }); + +// logger.info('info log'); +// expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\] \[A\] info log/)); +// }); + +// it('should child()', () => { +// const logger = new Logger('A'); +// const childLogger = logger.child('B', { showTag: true, showTime: true }); +// expect(childLogger === logger.child('B')); +// childLogger.warn('info log'); +// expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\]\s{3}\[A:B\] info log/)); +// }); +// }); diff --git a/test/plugins/validator.test.ts b/test/plugins/validator.test.ts new file mode 100644 index 0000000..dd8100e --- /dev/null +++ b/test/plugins/validator.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { runner } from '../../src/index'; + +describe('test/plugins/validator.test.ts', () => { + it('should stdout() / stderr()', async () => { + await runner() + .spawn('node', ['-p', 'process.version', '--inspect']) + .stdout(/v\d+\.\d+\.\d+/) + .notStdout('some text') + .stderr(/Debugger listening on/) + .notStderr('some error') + .end(); + + await runner() + .fork('test/fixtures/process/fork.ts') + .stdout(/v\d+\.\d+\.\d+/) + .notStdout('some text') + .stderr(/this is testing/) + .notStderr('some error') + .end(); + }); + + it('should expect()', async () => { + await runner() + .spawn('npm', ['-v']) + .expect(ctx => { + assert.equal(ctx.result.exitCode, 0); + }) + .end(); + + await assert.rejects(async () => { + await runner() + .spawn('npm', ['-v']) + .expect(ctx => { + assert.equal(ctx.result.exitCode, 1); + }) + .end(); + }, /Expected values to be strictly equal/); + }); + + describe.only('error stack', () => { + it('should correct error stack', async function test_stack() { + try { + await runner() + .spawn('npm', ['-v']) + .stdout(/abc/) + .end(); + } catch (err) { + console.error(err); + const index = err.stack!.indexOf(' at '); + const lineEndIndex = err.stack!.indexOf('\n', index); + const line = err.stack!.slice(index, lineEndIndex); + // console.log(line); + assert(line.startsWith(' at Context.test_stack')); + } + }); + + it('should not correct error stack', async function test_stack() { + try { + await runner() + .spawn('npm', ['-v']) + .expect(ctx => { + assert.equal(ctx.result.stdout, 1); + }) + .stdout(/abc/) + .end(); + } catch (err) { + console.error(err); + const index = err.stack!.indexOf(' at '); + const lineEndIndex = err.stack!.indexOf('\n', index); + const line = err.stack!.slice(index, lineEndIndex); + assert(line.startsWith(' at TestRunner.')); + } + }); + }); +}); diff --git a/test/runner.test.ts b/test/runner.test.ts new file mode 100644 index 0000000..f3a218b --- /dev/null +++ b/test/runner.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { runner } from '../src/index'; + +describe('test/runner.test.ts', () => { + describe('process', () => { + it('should spawn', async () => { + await runner() + .spawn('node', [ '-p', 'process.version', '--inspect' ]) + .stdout(/v\d+\.\d+\.\d+/) + .notStdout('some text') + .stderr(/Debugger listening on/) + .notStderr('some error') + .end(); + }); + + it('should fork', async () => { + await runner() + .fork('test/fixtures/process/fork.ts') + .stdout(/v\d+\.\d+\.\d+/) + .notStdout('some text') + .stderr(/this is testing/) + .notStderr('some error') + .end(); + }); + + it('should color', async () => { + await runner() + .fork('test/fixtures/process/color.ts') + .stdout(/CLIHub/) + .end(); + }); + + describe('code()', () => { + it('should skip auto check code when .code(1)', async () => { + await runner() + .fork('test/fixtures/process/error.ts') + .code(1) + .end(); + }); + + it('should auto check code when fail', async () => { + await assert.rejects(async () => { + await runner() + .fork('test/fixtures/process/error.ts') + .end(); + }, /Command failed with exit code 1/); + }); + }); + + it('should correct error stack', async function test_stack() { + try { + await runner() + .spawn('npm', [ '-v' ]) + .stdout(/abc/) + .end(); + } catch (err) { + const index = err.stack!.indexOf(' at '); + const lineEndIndex = err.stack!.indexOf('\n', index); + const line = err.stack!.slice(index, lineEndIndex); + // console.log(line); + assert(line.startsWith(' at Context.test_stack')); + } + }); + + // it.only('should wait stdout', async () => { + // await runner() + // .fork('test/fixtures/process/long-run.ts') + // .wait('stdout', /Server running at/) + // .stdout(/ready/) + // .end(); + // }); + + it.skip('should not fail when spawn not exists', async () => { + await runner() + .spawn('not-exists') + .stderr(/Cannot find module/) + .end(); + }); + + it.skip('should not fail when fork not exists', async () => { + await runner() + .fork('test/fixtures/not-exists.ts') + .stderr(/Cannot find module/) + .end(); + }); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..d31f6c1 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "inlineSourceMap": true, + }, + "ts-node": { + "transpileOnly": true, + "require": [] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/test/utils.test.js b/test/utils.test.ts similarity index 58% rename from test/utils.test.js rename to test/utils.test.ts index b7c1595..ea9f676 100644 --- a/test/utils.test.js +++ b/test/utils.test.ts @@ -1,12 +1,12 @@ -import { it, describe, beforeEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import { strict as assert } from 'assert'; -import * as utils from '../lib/utils.js'; -import * as testUtils from './test-utils.js'; +import * as utils from '../src/lib/utils'; +import * as testUtils from '../test-old/test-utils'; + +describe('test/utils.test.ts', () => { + const tmpDir = path.join(__dirname, './tmp'); -describe('test/utils.test.js', () => { - const tmpDir = testUtils.getTempDir(); beforeEach(() => testUtils.initDir(tmpDir)); it('types', () => { @@ -43,11 +43,17 @@ describe('test/utils.test.js', () => { }); it('writeFile', async () => { - await utils.writeFile(`${tmpDir}/test.md`, 'this is a test'); - assert(fs.readFileSync(`${tmpDir}/test.md`, 'utf-8') === 'this is a test'); + const targetDir = path.resolve(tmpDir, './b'); + await utils.mkdir(targetDir); + + const fileName = `${targetDir}/test-${Date.now()}.md`; + await utils.writeFile(`${tmpDir}/${fileName}.md`, 'this is a test'); + assert(fs.readFileSync(`${tmpDir}/${fileName}.md`, 'utf-8') === 'this is a test'); + + await utils.writeFile(`${tmpDir}/${fileName}.json`, { name: 'test' }); + assert(fs.readFileSync(`${tmpDir}/${fileName}.json`, 'utf-8').match(/"name": "test"/)); - await utils.writeFile(`${tmpDir}/test.json`, { name: 'test' }); - assert(fs.readFileSync(`${tmpDir}/test.json`, 'utf-8').match(/"name": "test"/)); + await utils.mkdir(targetDir); }); it('exists', async () => { @@ -55,16 +61,16 @@ describe('test/utils.test.js', () => { assert(!await utils.exists('not-exists-file')); }); - it('resolve meta', async () => { - const p = utils.resolve(import.meta, '../test', './fixtures'); - const isExist = await utils.exists(p); - assert(isExist); - }); + // it('resolve meta', async () => { + // const p = utils.resolve(import.meta, '../test', './fixtures'); + // const isExist = await utils.exists(p); + // assert(isExist); + // }); - it('resolve', async () => { - const p = utils.resolve('test', './fixtures'); - assert(fs.existsSync(p)); - }); + // it('resolve', async () => { + // const p = utils.resolve('test', './fixtures'); + // assert(fs.existsSync(p)); + // }); it('sleep', async () => { const start = Date.now(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e3140b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@artus/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "strictNullChecks": true, + "resolveJsonModule": true, + "outDir": "dist" + }, + "include": [ + "src/**/*.ts", + "src/**/*.json" + ] +} diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index a0e14ea..0000000 --- a/vitest.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - // plugins: [ { - // name: 'vitest-setup-plugin', - // config: () => ({ - // test: { - // setupFiles: [ - // './setupFiles/add-something-to-global.ts', - // 'setupFiles/without-relative-path-prefix.ts', - // ], - // }, - // }), - // } ], - test: { - // globals: true, - globalSetup: [ - './test/setup.js', - ], - }, -});