From 35406feb9eed76a768a6d0117b3d23f9126efae1 Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 5 Aug 2023 15:18:19 +0800 Subject: [PATCH] fix: convert unhandled rejection to uncaught exception (#235) Mocha do not catch unhandled rejection by default. Case will faield until timeout. set `--unhandled-rejections` to strict, let case fail fast. --- .gitignore | 3 +- lib/cmd/test.js | 6 +- src/cmd/test.ts | 218 ++++++++++++++++++ .../test-unhandled-rejection/package.json | 6 + .../test-unhandled-rejection/test/a.test.js | 5 + test/lib/cmd/test.test.js | 8 + 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 src/cmd/test.ts create mode 100644 test/fixtures/test-unhandled-rejection/package.json create mode 100644 test/fixtures/test-unhandled-rejection/test/a.test.js diff --git a/.gitignore b/.gitignore index b3c359bf..74b1ea44 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,9 @@ test/fixtures/example-ts-ets/typings/ **/run/*.json .tmp .vscode +.idea .cache *.log package-lock.json .nyc_output -yarn.lock \ No newline at end of file +yarn.lock diff --git a/lib/cmd/test.js b/lib/cmd/test.js index d581c48c..02abb610 100644 --- a/lib/cmd/test.js +++ b/lib/cmd/test.js @@ -57,7 +57,11 @@ class TestCommand extends Command { env: Object.assign({ NODE_ENV: 'test', }, context.env), - execArgv: context.execArgv, + execArgv: [ + ...context.execArgv, + // https://github.com/mochajs/mocha/issues/2640#issuecomment-1663388547 + '--unhandled-rejections=strict', + ], }; const mochaFile = require.resolve('mocha/bin/_mocha'); const testArgs = yield this.formatTestArgs(context); diff --git a/src/cmd/test.ts b/src/cmd/test.ts new file mode 100644 index 00000000..40eb5ec4 --- /dev/null +++ b/src/cmd/test.ts @@ -0,0 +1,218 @@ +import { debuglog } from 'node:util'; +import os from 'node:os'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + DefineCommand, Option, +} from '@artus-cli/artus-cli'; +import globby from 'globby'; +import { getChangedFilesForRoots } from 'jest-changed-files'; +import { BaseCommand } from './base'; + +const debug = debuglog('egg-bin:test'); + +@DefineCommand({ + command: 'test [files...]', + description: 'Run the test', + alias: [ 't' ], +}) +export class TestCommand extends BaseCommand { + @Option({ + default: [], + array: true, + type: 'string', + }) + files: string[]; + + @Option({ + description: 'set test-case timeout in milliseconds, default is 60000', + alias: 't', + default: process.env.TEST_TIMEOUT ?? 60000, + }) + timeout: number | boolean; + + @Option({ + description: 'only run tests matching ', + alias: 'g', + type: 'string', + array: true, + default: [], + }) + grep: string[]; + + @Option({ + description: 'only test with changed files and match test/**/*.test.(js|ts), default is false', + alias: 'c', + type: 'boolean', + default: false, + }) + changed: boolean; + + @Option({ + description: 'mocha parallel mode, default is false', + alias: 'p', + type: 'boolean', + default: false, + }) + parallel: boolean; + + @Option({ + description: 'number of jobs to run in parallel', + type: 'number', + default: os.cpus().length - 1, + }) + jobs: number; + + @Option({ + description: 'auto bootstrap agent in mocha master process, default is true', + type: 'boolean', + default: true, + }) + autoAgent: boolean; + + @Option({ + description: 'enable mochawesome reporter, default is true', + type: 'boolean', + default: true, + }) + mochawesome: boolean; + + @Option({ + description: 'bbort ("bail") after first test failure', + alias: 'b', + type: 'boolean', + default: false, + }) + bail: boolean; + + async run() { + try { + await fs.access(this.base); + } catch (err) { + console.error('baseDir: %o not exists', this.base); + throw err; + } + + const mochaFile = process.env.MOCHA_FILE || require.resolve('mocha/bin/_mocha'); + if (this.parallel) { + this.ctx.env.ENABLE_MOCHA_PARALLEL = 'true'; + if (this.autoAgent) { + this.ctx.env.AUTO_AGENT = 'true'; + } + } + // set NODE_ENV=test, let egg application load unittest logic + // https://eggjs.org/basics/env#difference-from-node_env + this.ctx.env.NODE_ENV = 'test'; + debug('run test: %s %o', mochaFile, this.ctx.args); + + const mochaArgs = await this.formatMochaArgs(); + if (!mochaArgs) return; + await this.forkNode(mochaFile, mochaArgs, { + execArgv: [ + ...process.execArgv, + // https://github.com/mochajs/mocha/issues/2640#issuecomment-1663388547 + '--unhandled-rejections=strict', + ], + }); + } + + protected async formatMochaArgs() { + // collect require + const requires = await this.formatRequires(); + try { + const eggMockRegister = require.resolve('egg-mock/register', { paths: [ this.base ] }); + requires.push(eggMockRegister); + debug('auto register egg-mock: %o', eggMockRegister); + } catch (err) { + // ignore egg-mock not exists + debug('auto register egg-mock fail, can not require egg-mock on %o, error: %s', + this.base, (err as Error).message); + } + + // handle mochawesome enable + let reporter = this.ctx.env.TEST_REPORTER; + let reporterOptions = ''; + if (!reporter && this.mochawesome) { + // use https://github.com/node-modules/mochawesome/pull/1 instead + reporter = require.resolve('mochawesome-with-mocha'); + reporterOptions = 'reportDir=node_modules/.mochawesome-reports'; + if (this.parallel) { + // https://github.com/adamgruber/mochawesome#parallel-mode + requires.push(require.resolve('mochawesome-with-mocha/register')); + } + } + + const ext = this.ctx.args.typescript ? 'ts' : 'js'; + let pattern = this.files; + // changed + if (this.changed) { + pattern = await this.getChangedTestFiles(this.base, ext); + if (!pattern.length) { + console.log('No changed test files'); + return; + } + debug('changed files: %o', pattern); + } + + if (!pattern.length && process.env.TESTS) { + pattern = process.env.TESTS.split(','); + } + + // collect test files when nothing is changed + if (!pattern.length) { + pattern = [ `test/**/*.test.${ext}` ]; + } + pattern = pattern.concat([ '!test/fixtures', '!test/node_modules' ]); + + // expand glob and skip node_modules and fixtures + const files = globby.sync(pattern, { cwd: this.base }); + files.sort(); + + if (files.length === 0) { + console.log(`No test files found with ${pattern}`); + return; + } + + // auto add setup file as the first test file + const setupFile = path.join(this.base, `test/.setup.${ext}`); + try { + await fs.access(setupFile); + files.unshift(setupFile); + } catch { + // ignore + } + + return [ + this.dryRun ? '--dry-run' : '', + // force exit + '--exit', + this.bail ? '--bail' : '', + this.grep.map(pattern => `--grep='${pattern}'`).join(' '), + this.timeout === false ? '--no-timeout' : `--timeout=${this.timeout}`, + this.parallel ? '--parallel' : '', + this.parallel && this.jobs ? `--jobs=${this.jobs}` : '', + reporter ? `--reporter=${reporter}` : '', + reporterOptions ? `--reporter-options=${reporterOptions}` : '', + ...requires.map(r => `--require=${r}`), + ...files, + ].filter(a => a.trim()); + } + + protected async getChangedTestFiles(dir: string, ext: string) { + const res = await getChangedFilesForRoots([ path.join(dir, 'test') ], {}); + const changedFiles = res.changedFiles; + const files: string[] = []; + for (let cf of changedFiles) { + // only find test/**/*.test.(js|ts) + if (cf.endsWith(`.test.${ext}`)) { + // Patterns MUST use forward slashes (not backslashes) + // This should be converted on Windows + if (process.platform === 'win32') { + cf = cf.replace(/\\/g, '/'); + } + files.push(cf); + } + } + return files; + } +} diff --git a/test/fixtures/test-unhandled-rejection/package.json b/test/fixtures/test-unhandled-rejection/package.json new file mode 100644 index 00000000..1633ac83 --- /dev/null +++ b/test/fixtures/test-unhandled-rejection/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-unhandled-rejection", + "files": [ + "lib" + ] +} diff --git a/test/fixtures/test-unhandled-rejection/test/a.test.js b/test/fixtures/test-unhandled-rejection/test/a.test.js new file mode 100644 index 00000000..61cdb364 --- /dev/null +++ b/test/fixtures/test-unhandled-rejection/test/a.test.js @@ -0,0 +1,5 @@ +describe('a.test.js', () => { + it('should success', () => { + Promise.reject(new Error('mock error')); + }); +}); diff --git a/test/lib/cmd/test.test.js b/test/lib/cmd/test.test.js index 0e8e0dfd..91e1419f 100644 --- a/test/lib/cmd/test.test.js +++ b/test/lib/cmd/test.test.js @@ -332,4 +332,12 @@ describe('test/lib/cmd/test.test.js', () => { .end(done); }); }); + + it('should failed with unhandled rejection', () => { + return coffee.fork(eggBin, [ 'test' ], { cwd: path.join(__dirname, '../../fixtures/test-unhandled-rejection') }) + .debug() + .expect('stdout', / Uncaught Error: mock error/) + .expect('code', 1) + .end(); + }); });