1
0
Fork 0

fix(tests): add async helpers for tests, wrap async tests, make chai external

This commit is contained in:
ssube 2019-10-28 19:31:47 -05:00 committed by Sean Sube
parent 6f1646d4a9
commit 0eb9d5107a
3 changed files with 323 additions and 1 deletions

View File

@ -1,8 +1,8 @@
import commonjs from 'rollup-plugin-commonjs';
import json from 'rollup-plugin-json';
import multiEntry from 'rollup-plugin-multi-entry';
import replace from 'rollup-plugin-replace';
import resolve from 'rollup-plugin-node-resolve';
import replace from 'rollup-plugin-replace';
import tslint from 'rollup-plugin-tslint';
import typescript from 'rollup-plugin-typescript2';
@ -11,6 +11,7 @@ const shebang = '#! /usr/bin/env node';
const bundle = {
external: [
'chai',
'dtrace-provider',
],
input: [

170
test/TestRule.ts Normal file
View File

@ -0,0 +1,170 @@
import { expect } from 'chai';
import { ConsoleLogger } from 'noicejs';
import { mock } from 'sinon';
import { makeSelector, resolveRules, Rule, visitRules } from '../src/rule';
import { VisitorContext } from '../src/visitor/VisitorContext';
import { describeLeaks, itLeaks } from './helpers/async';
const TEST_RULES = [new Rule({
check: {},
desc: '',
level: 'info',
name: 'foo',
select: '$',
tags: ['all', 'foo'],
}), new Rule({
check: {},
desc: '',
level: 'warn',
name: 'bar',
select: '$',
tags: ['all', 'test'],
}), new Rule({
check: {},
desc: '',
level: 'warn',
name: 'bin',
select: '$',
tags: ['all', 'test'],
})];
describeLeaks('rule resolver', async () => {
describeLeaks('include by level', async () => {
itLeaks('should include info rules', async () => {
const info = await resolveRules(TEST_RULES, makeSelector({
includeLevel: ['info'],
}));
expect(info.length).to.equal(1);
expect(info[0]).to.equal(TEST_RULES[0]);
});
itLeaks('should include warn rules', async () => {
const info = await resolveRules(TEST_RULES, makeSelector({
includeLevel: ['warn'],
}));
expect(info.length).to.equal(2);
expect(info[0]).to.equal(TEST_RULES[1]);
expect(info[1]).to.equal(TEST_RULES[2]);
});
});
describeLeaks('include by name', async () => {
itLeaks('should include foo rules', async () => {
const rules = await resolveRules(TEST_RULES, makeSelector({
includeName: ['foo'],
}));
expect(rules.length).to.equal(1);
expect(rules[0].name).to.equal('foo');
});
});
describeLeaks('include by tag', async () => {
itLeaks('should include test rules', async () => {
const rules = await resolveRules(TEST_RULES, makeSelector({
includeTag: ['test'],
}));
expect(rules.length).to.equal(2);
expect(rules[0]).to.equal(TEST_RULES[1]);
expect(rules[1]).to.equal(TEST_RULES[2]);
});
});
describeLeaks('exclude by name', async () => {
itLeaks('should exclude foo rules', async () => {
const rules = await resolveRules(TEST_RULES, makeSelector({
excludeName: ['foo'],
includeTag: ['all'],
}));
expect(rules.length).to.equal(2);
expect(rules[0]).to.equal(TEST_RULES[1]);
expect(rules[1]).to.equal(TEST_RULES[2]);
});
});
describeLeaks('exclude by tag', async () => {
itLeaks('should exclude test rules', async () => {
const rules = await resolveRules(TEST_RULES, makeSelector({
excludeTag: ['test'],
includeTag: ['all'],
}));
expect(rules.length).to.equal(1);
expect(rules[0]).to.equal(TEST_RULES[0]);
});
});
});
describeLeaks('rule visitor', async () => {
itLeaks('should only call visit for selected items', async () => {
const ctx = new VisitorContext({
innerOptions: {
coerce: false,
defaults: false,
mutate: false,
},
logger: new ConsoleLogger(),
});
const data = {};
const rule = new Rule({
check: {},
desc: '',
level: 'info',
name: 'foo',
select: '$',
tags: [],
});
const mockRule = mock(rule);
mockRule.expects('visit').never();
const pickStub = mockRule.expects('pick').once().withArgs(ctx, data);
pickStub.onFirstCall().returns(Promise.resolve([]));
pickStub.throws();
await visitRules(ctx, [rule], {});
mockRule.verify();
expect(ctx.errors.length).to.equal(0);
});
itLeaks('should call visit for each selected item', async () => {
const ctx = new VisitorContext({
innerOptions: {
coerce: false,
defaults: false,
mutate: false,
},
logger: new ConsoleLogger(),
});
const data = {};
const rule = new Rule({
check: {},
desc: '',
level: 'info',
name: 'foo',
select: '$',
tags: [],
});
const mockRule = mock(rule);
const pickStub = mockRule.expects('pick').once().withArgs(ctx, data);
pickStub.onFirstCall().returns(Promise.resolve([data]));
pickStub.throws();
const visitStub = mockRule.expects('visit').once().withArgs(ctx, data);
visitStub.onFirstCall().returns(Promise.resolve(ctx));
visitStub.throws();
await visitRules(ctx, [rule], {});
mockRule.verify();
expect(ctx.errors.length).to.equal(0);
});
});

151
test/helpers/async.ts Normal file
View File

@ -0,0 +1,151 @@
import { AsyncHook, createHook } from 'async_hooks';
import { isNil } from 'lodash';
// this will pull Mocha internals out of the stacks
// tslint:disable-next-line:no-var-requires
const { stackTraceFilter } = require('mocha/lib/utils');
const filterStack = stackTraceFilter();
type AsyncMochaTest = (this: Mocha.Context | void) => Promise<void>;
type AsyncMochaSuite = (this: Mocha.Suite) => Promise<void>;
export interface TrackedResource {
source: string;
triggerAsyncId: number;
type: string;
}
function debugMode() {
return Reflect.has(process.env, 'DEBUG');
}
/**
* Async resource tracker using node's internal hooks.
*
* This probably won't work in a browser. It does not hold references to the resource, to avoid leaks.
* Adapted from https://gist.github.com/boneskull/7fe75b63d613fa940db7ec990a5f5843#file-async-dump-js
*/
export class Tracker {
public static getStack(): string {
const err = new Error();
if (isNil(err.stack)) {
return 'no stack trace available';
} else {
return filterStack(err.stack);
}
}
private readonly hook: AsyncHook;
private readonly resources: Map<number, TrackedResource>;
constructor() {
this.resources = new Map();
this.hook = createHook({
destroy: (id: number) => {
this.resources.delete(id);
},
init: (id: number, type: string, triggerAsyncId: number) => {
const source = Tracker.getStack();
// @TODO: exclude async hooks, including this one
this.resources.set(id, {
source,
triggerAsyncId,
type,
});
},
promiseResolve: (id: number) => {
this.resources.delete(id);
},
});
}
public clear() {
this.resources.clear();
}
public disable() {
this.hook.disable();
}
public dump() {
/* tslint:disable:no-console */
console.error(`tracking ${this.resources.size} async resources`);
this.resources.forEach((res, id) => {
console.error(`${id}: ${res.type}`);
if (debugMode()) {
console.error(res.source);
console.error('\n');
}
});
/* tslint:enable:no-console */
}
public enable() {
this.hook.enable();
}
public get size(): number {
return this.resources.size;
}
}
/**
* Describe a suite of async tests. This wraps mocha's describe to track async resources and report leaks.
*/
export function describeLeaks(description: string, cb: AsyncMochaSuite): Mocha.Suite {
return describe(description, function trackSuite(this: Mocha.Suite) {
const tracker = new Tracker();
beforeEach(() => {
tracker.enable();
});
afterEach(() => {
tracker.disable();
const leaked = tracker.size;
// @TODO: this should only exclude the single Immediate set by the Tracker
if (leaked > 1) {
tracker.dump();
const msg = `test leaked ${leaked - 1} async resources`;
if (debugMode()) {
throw new Error(msg);
} else {
// tslint:disable-next-line:no-console
console.warn(msg);
}
}
tracker.clear();
});
const suite: PromiseLike<void> | undefined = cb.call(this);
if (isNil(suite) || !Reflect.has(suite, 'then')) {
// tslint:disable-next-line:no-console
console.error(`test suite '${description}' did not return a promise`);
}
return suite;
});
}
/**
* Run an asynchronous test with unhandled rejection guards.
*
* This function may not have any direct test coverage. It is too simple to reasonably mock.
*/
export function itLeaks(expectation: string, cb?: AsyncMochaTest): Mocha.Test {
if (isNil(cb)) {
return it(expectation);
}
return it(expectation, function trackTest(this: Mocha.Context) {
return new Promise<unknown>((res, rej) => {
cb.call(this).then((value: unknown) => {
res(value);
}, (err: Error) => {
rej(err);
});
});
});
}