Skip to content

Commit

Permalink
fix: convert unhandled rejection to uncaught exception (#235)
Browse files Browse the repository at this point in the history
Mocha do not catch unhandled rejection by default. Case
will faield until timeout. set `--unhandled-rejections`
to strict, let case fail fast.
  • Loading branch information
killagu committed Aug 7, 2023
1 parent 11dcfee commit 35406fe
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test/fixtures/example-ts-ets/typings/
**/run/*.json
.tmp
.vscode
.idea
.cache
*.log
package-lock.json
.nyc_output
yarn.lock
yarn.lock
6 changes: 5 additions & 1 deletion lib/cmd/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
218 changes: 218 additions & 0 deletions src/cmd/test.ts
Original file line number Diff line number Diff line change
@@ -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 <pattern>',
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;
}
}
6 changes: 6 additions & 0 deletions test/fixtures/test-unhandled-rejection/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test-unhandled-rejection",
"files": [
"lib"
]
}
5 changes: 5 additions & 0 deletions test/fixtures/test-unhandled-rejection/test/a.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
describe('a.test.js', () => {
it('should success', () => {
Promise.reject(new Error('mock error'));
});
});
8 changes: 8 additions & 0 deletions test/lib/cmd/test.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

0 comments on commit 35406fe

Please sign in to comment.