From 0eb9d5107ae0e71e3409b7d69c6c4443a8c66dac Mon Sep 17 00:00:00 2001 From: ssube Date: Mon, 28 Oct 2019 19:31:47 -0500 Subject: [PATCH] fix(tests): add async helpers for tests, wrap async tests, make chai external --- config/rollup.js | 3 +- test/TestRule.ts | 170 ++++++++++++++++++++++++++++++++++++++++++ test/helpers/async.ts | 151 +++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 test/TestRule.ts create mode 100644 test/helpers/async.ts diff --git a/config/rollup.js b/config/rollup.js index b45c6b5..b126a8b 100644 --- a/config/rollup.js +++ b/config/rollup.js @@ -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: [ diff --git a/test/TestRule.ts b/test/TestRule.ts new file mode 100644 index 0000000..52f7aa3 --- /dev/null +++ b/test/TestRule.ts @@ -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); + }); +}); diff --git a/test/helpers/async.ts b/test/helpers/async.ts new file mode 100644 index 0000000..0807605 --- /dev/null +++ b/test/helpers/async.ts @@ -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; +type AsyncMochaSuite = (this: Mocha.Suite) => Promise; + +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; + + 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 | 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((res, rej) => { + cb.call(this).then((value: unknown) => { + res(value); + }, (err: Error) => { + rej(err); + }); + }); + }); +}