1
0
Fork 0

remove: async test tracker and pid file utils

BREAKING CHANGE: this removes the test helpers, which were not well
tested and required the `async_hooks` module, and the PID file
helpers, which introduced a requirement on `fs` that could not be
easily polyfilled. This should make the library easier to use in
browsers and bundlers.
This commit is contained in:
ssube 2020-06-30 08:14:30 -05:00
parent a7cf22de07
commit e34641a42d
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
16 changed files with 82 additions and 361 deletions

View File

@ -1,96 +0,0 @@
import { AsyncHook, createHook } from 'async_hooks';
import { isDebug } from './Env';
import { isNil, Optional } from './Maybe';
export interface TrackedResource {
source: string;
triggerAsyncId: number;
type: string;
}
export type StackFilter = (stack: string) => string;
/**
* 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
*
* @public
*/
export class AsyncTracker {
public filter: Optional<StackFilter>;
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 = this.getStack();
// @TODO: exclude async hooks, including this one
this.resources.set(id, {
source,
triggerAsyncId,
type,
});
},
promiseResolve: (id: number) => {
this.resources.delete(id);
},
});
}
/**
* Get a filtered version of the current call stack. This creates a new error to generate the
* stack trace and will be quite slow.
*/
public getStack(): string {
const err = new Error();
if (isNil(err.stack)) {
return 'no stack trace available';
}
if (isNil(this.filter)) {
return err.stack;
}
return this.filter(err.stack);
}
public clear() {
this.resources.clear();
}
public disable() {
this.hook.disable();
}
/**
* Print a listing of all tracked resources. When debug mode is enabled (DEBUG=TRUE), include
* stack traces.
*/
/* eslint-disable no-console, no-invalid-this */
public dump() {
console.error(`tracking ${this.resources.size} async resources`);
this.resources.forEach((res, id) => {
console.error(`${id}: ${res.type}`);
if (isDebug()) {
console.error(res.source);
console.error('\n');
}
});
}
public enable() {
this.hook.enable();
}
public get size(): number {
return this.resources.size;
}
}

View File

@ -1,44 +0,0 @@
import { open, unlink, write } from 'fs';
import { pid } from 'process';
import { doesExist, Optional } from './Maybe';
type OptionalErrno = Optional<NodeJS.ErrnoException>;
/**
* Write the current process ID to a file at the given `path`.
*
* @public
*/
export async function writePid(path: string): Promise<void> {
return new Promise((res, rej) => {
open(path, 'wx', (openErr: OptionalErrno, fd: number) => {
if (doesExist(openErr)) {
rej(openErr);
} else {
write(fd, pid.toString(), 0, 'utf8', (writeErr: OptionalErrno) => {
if (doesExist(writeErr)) {
rej(writeErr);
} else {
res();
}
});
}
});
});
}
/**
* Remove the file at the given `path`.
*/
export async function removePid(path: string): Promise<void> {
return new Promise((res, rej) => {
unlink(path, (err: OptionalErrno) => {
if (doesExist(err)) {
rej(err);
} else {
res();
}
});
});
}

View File

@ -5,9 +5,6 @@ export { NotFoundError } from './error/NotFoundError';
export { NotImplementedError } from './error/NotImplementedError';
export { TimeoutError } from './error/TimeoutError';
export {
AsyncTracker,
} from './AsyncTracker';
export {
ArrayMapper,
ArrayMapperOptions,
@ -71,10 +68,6 @@ export {
mustExist,
mustFind,
} from './Maybe';
export {
removePid,
writePid,
} from './PidFile';
export {
constructorName,
getConstructor,

View File

@ -2,10 +2,9 @@ import { expect } from 'chai';
import { spy } from 'sinon';
import { main } from '../src/app';
import { describeLeaks, itLeaks } from './helpers/async';
describeLeaks('app', async () => {
itLeaks('should log a message', async () => {
describe('app', async () => {
it('should log a message', async () => {
/* tslint:disable-next-line:no-console no-unbound-method */
const logSpy = spy(console, 'log');

View File

@ -1,6 +0,0 @@
import { describeLeaks, itLeaks } from './helpers/async';
describeLeaks('test helpers', async () => {
itLeaks('should wrap suites');
itLeaks('should wrap tests');
});

View File

@ -1,74 +0,0 @@
import { AsyncTracker } from '../../src/AsyncTracker';
import { isNil } from '../../src/Maybe';
import { isDebug } from '../../src/Env';
// this will pull Mocha internals out of the stacks
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const { stackTraceFilter } = require('mocha/lib/utils');
type AsyncMochaTest = (this: Mocha.Context | void) => Promise<void>;
type AsyncMochaSuite = (this: Mocha.Suite) => Promise<void>;
/**
* 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 AsyncTracker();
tracker.filter = stackTraceFilter;
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 (isDebug()) {
throw new Error(msg);
} else {
/* eslint-disable-next-line no-console */
console.warn(msg);
}
}
tracker.clear();
});
/* eslint-disable-next-line no-invalid-this */
const suite: PromiseLike<void> | undefined = cb.call(this);
if (isNil(suite) || !Reflect.has(suite, 'then')) {
/* eslint-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) => {
/* eslint-disable-next-line no-invalid-this */
cb.call(this).then((value: unknown) => {
res(value);
}, (err: Error) => {
rej(err);
});
});
});
}

View File

@ -1,11 +1,10 @@
import { expect } from 'chai';
import { ArrayMapper } from '../../src/ArrayMapper';
import { describeLeaks, itLeaks } from '../helpers/async';
describeLeaks('utils', async () => {
describeLeaks('array mapper', async () => {
itLeaks('should take initial args', async () => {
describe('utils', async () => {
describe('array mapper', async () => {
it('should take initial args', async () => {
const mapper = new ArrayMapper({
rest: 'others',
skip: 0,
@ -18,7 +17,7 @@ describeLeaks('utils', async () => {
expect(results.get('others'), 'rest should be collected').to.deep.equal(['3', '4']);
});
itLeaks('should always include rest arg', async () => {
it('should always include rest arg', async () => {
const mapper = new ArrayMapper({
rest: 'empty',
skip: 0,
@ -29,7 +28,7 @@ describeLeaks('utils', async () => {
expect(results.get('empty'), 'rest key should be empty').to.have.lengthOf(0);
});
itLeaks('should skit initial args', async () => {
it('should skit initial args', async () => {
const mapper = new ArrayMapper({
rest: 'empty',
skip: 3,

View File

@ -2,14 +2,13 @@ import { expect } from 'chai';
import { defer, timeout } from '../../src/Async';
import { TimeoutError } from '../../src/error/TimeoutError';
import { describeLeaks, itLeaks } from '../helpers/async';
describeLeaks('async utils', async () => {
describeLeaks('defer', async () => {
itLeaks('should resolve', async () => expect(defer(10, true)).to.eventually.equal(true));
describe('async utils', async () => {
describe('defer', async () => {
it('should resolve', async () => expect(defer(10, true)).to.eventually.equal(true));
});
describeLeaks('timeout', async () => {
itLeaks('should reject slow promises', async () => expect(timeout(10, defer(20))).to.eventually.be.rejectedWith(TimeoutError));
describe('timeout', async () => {
it('should reject slow promises', async () => expect(timeout(10, defer(20))).to.eventually.be.rejectedWith(TimeoutError));
});
});

View File

@ -1,11 +1,10 @@
import { expect } from 'chai';
import { concat, encode } from '../../src/Buffer';
import { describeLeaks, itLeaks } from '../helpers/async';
describeLeaks('buffer utils', async () => {
describeLeaks('concat', async () => {
itLeaks('should append chunk buffers', async () => {
describe('buffer utils', async () => {
describe('concat', async () => {
it('should append chunk buffers', async () => {
expect(concat([
Buffer.from('hello'),
Buffer.from('world'),
@ -13,18 +12,18 @@ describeLeaks('buffer utils', async () => {
});
});
describeLeaks('encode', async () => {
itLeaks('should encode chunk buffers', async () => {
describe('encode', async () => {
it('should encode chunk buffers', async () => {
expect(encode([
Buffer.from('hello world'),
], 'utf-8')).to.equal('hello world');
});
itLeaks('should encode no buffers', async () => {
it('should encode no buffers', async () => {
expect(encode([], 'utf-8')).to.equal('');
});
itLeaks('should encode empty buffers', async () => {
it('should encode empty buffers', async () => {
expect(encode([
new Buffer(0),
], 'utf-8')).to.equal('');

View File

@ -1,16 +1,15 @@
import { expect } from 'chai';
import { Checklist, ChecklistMode } from '../../src/Checklist';
import { describeLeaks, itLeaks } from '../helpers/async';
const EXISTING_ITEM = 'foo';
const MISSING_ITEM = 'bin';
const TEST_DATA = [EXISTING_ITEM, 'bar'];
// tslint:disable:no-duplicate-functions
describeLeaks('checklist', async () => {
describeLeaks('exclude mode', async () => {
itLeaks('should check for present values', async () => {
describe('checklist', async () => {
describe('exclude mode', async () => {
it('should check for present values', async () => {
const list = new Checklist({
data: TEST_DATA,
mode: ChecklistMode.EXCLUDE,
@ -18,7 +17,7 @@ describeLeaks('checklist', async () => {
expect(list.check(EXISTING_ITEM)).to.equal(false);
});
itLeaks('should check for missing values', async () => {
it('should check for missing values', async () => {
const list = new Checklist({
data: TEST_DATA,
mode: ChecklistMode.EXCLUDE,
@ -27,8 +26,8 @@ describeLeaks('checklist', async () => {
});
});
describeLeaks('include mode', async () => {
itLeaks('should check for present values', async () => {
describe('include mode', async () => {
it('should check for present values', async () => {
const list = new Checklist<string>({
data: TEST_DATA,
mode: ChecklistMode.INCLUDE,
@ -36,7 +35,7 @@ describeLeaks('checklist', async () => {
expect(list.check(EXISTING_ITEM)).to.equal(true);
});
itLeaks('should check for missing values', async () => {
it('should check for missing values', async () => {
const list = new Checklist<string>({
data: TEST_DATA,
mode: ChecklistMode.INCLUDE,

View File

@ -3,7 +3,6 @@ import { expect } from 'chai';
import { ChildProcessError } from '../../src';
import { ChildStreams, waitForChild } from '../../src/Child';
import { mustExist, Optional } from '../../src/Maybe';
import { describeLeaks, itLeaks } from '../helpers/async';
type Closer = (status: number) => Promise<void>;
@ -29,9 +28,9 @@ function createChild(): ChildStreams & { closer: Optional<Closer> } {
} as any;
}
describeLeaks('child process utils', async () => {
describeLeaks('wait for child helper', async () => {
itLeaks('should read stdout data', async () => {
describe('child process utils', async () => {
describe('wait for child helper', async () => {
it('should read stdout data', async () => {
const child = createChild();
const resultPromise = waitForChild(child);
@ -41,10 +40,10 @@ describeLeaks('child process utils', async () => {
expect(result.status).to.equal(0);
});
itLeaks('should read stderr data');
itLeaks('should resolve on success status');
it('should read stderr data');
it('should resolve on success status');
itLeaks('should reject on failure status', async () => {
it('should reject on failure status', async () => {
const child = createChild();
const resultPromise = waitForChild(child);

View File

@ -15,7 +15,6 @@ import {
pushMergeMap,
normalizeMap,
} from '../../src/Map';
import { describeLeaks, itLeaks } from '../helpers/async';
const DEFAULT_VALUE = 'default';
const mapKey = 'key';
@ -29,22 +28,22 @@ const multiItem = new Map([
/* eslint-enable */
]);
describeLeaks('map utils', async () => {
describeLeaks('make dict', async () => {
itLeaks('should return an empty dict for nil values', async () => {
describe('map utils', async () => {
describe('make dict', async () => {
it('should return an empty dict for nil values', async () => {
/* eslint-disable-next-line no-null/no-null */
expect(makeDict(null)).to.deep.equal({});
expect(makeDict(undefined)).to.deep.equal({});
});
itLeaks('should return an existing dict', async () => {
it('should return an existing dict', async () => {
const input = {};
expect(makeDict(input)).to.equal(input);
});
});
describeLeaks('make map', async () => {
itLeaks('should convert objects to maps', async () => {
describe('make map', async () => {
it('should convert objects to maps', async () => {
const data = {
bar: '2',
foo: '1',
@ -55,44 +54,44 @@ describeLeaks('map utils', async () => {
});
});
describeLeaks('must get helper', async () => {
itLeaks('should get existing keys', async () => {
describe('must get helper', async () => {
it('should get existing keys', async () => {
expect(mustGet(singleItem, mapKey)).to.equal(mapValue);
});
itLeaks('should throw on missing keys', async () => {
it('should throw on missing keys', async () => {
expect(() => {
mustGet(singleItem, 'nope');
}).to.throw(NotFoundError);
});
});
describeLeaks('get head helper', async () => {
itLeaks('should get the first item from existing keys', async () => {
describe('get head helper', async () => {
it('should get the first item from existing keys', async () => {
expect(getHead(multiItem, mapKey)).to.equal(mapValue);
});
itLeaks('should throw on missing keys', async () => {
it('should throw on missing keys', async () => {
expect(() => {
getHead(multiItem, 'nope');
}).to.throw(NotFoundError);
});
});
describeLeaks('get head or default helper', async () => {
itLeaks('should get the first item from existing keys', async () => {
describe('get head or default helper', async () => {
it('should get the first item from existing keys', async () => {
expect(getHeadOrDefault(multiItem, mapKey, 'nope')).to.equal(mapValue);
});
itLeaks('should get the default for missing keys', async () => {
it('should get the default for missing keys', async () => {
expect(getHeadOrDefault(multiItem, 'nope', mapValue)).to.equal(mapValue);
});
itLeaks('should return the default value for nil values', async () => {
it('should return the default value for nil values', async () => {
expect(getHeadOrDefault(multiItem, 'nilValue', mapValue)).to.equal(mapValue);
});
itLeaks('should return the default value for nil keys', async () => {
it('should return the default value for nil keys', async () => {
expect(getHeadOrDefault(multiItem, 'nilKey', mapValue)).to.equal(mapValue);
});
});

View File

@ -1,42 +0,0 @@
import { expect } from 'chai';
import mockFS from 'mock-fs';
import { removePid, writePid } from '../../src';
import { describeLeaks, itLeaks } from '../helpers/async';
const PID_PATH = 'foo';
const PID_NAME = 'foo/test.pid';
describeLeaks('pid file utils', async () => {
beforeEach(() => {
mockFS({
[PID_PATH]: mockFS.directory(),
});
});
afterEach(() => {
mockFS.restore();
});
itLeaks('should create a marker', async () => {
await writePid(PID_NAME);
mockFS.restore();
});
itLeaks('should not replace an existing marker', async () => {
await writePid(PID_NAME);
return expect(writePid(PID_PATH)).to.eventually.be.rejectedWith(Error);
});
itLeaks('should remove an existing marker', async () => {
await writePid(PID_NAME);
await removePid(PID_NAME);
mockFS.restore();
});
itLeaks('should fail to remove a missing marker', async () =>
expect(removePid(PID_PATH)).to.eventually.be.rejectedWith(Error)
);
});

View File

@ -2,12 +2,11 @@ import { expect } from 'chai';
import { timeout } from '../../src/Async';
import { signal, SIGNAL_RESET } from '../../src/Signal';
import { describeLeaks, itLeaks } from '../helpers/async';
const MAX_SIGNAL_TIME = 500;
describeLeaks('signal utils', async () => {
itLeaks('should wait for a signal', async () => {
describe('signal utils', async () => {
it('should wait for a signal', async () => {
const signalPromise = signal(SIGNAL_RESET);
process.kill(process.pid, SIGNAL_RESET);

View File

@ -1,39 +1,38 @@
import { expect } from 'chai';
import { leftPad, trim } from '../../src/String';
import { describeLeaks, itLeaks } from '../helpers/async';
const TEST_SHORT = 'hello';
const TEST_LONG = 'hello world';
describeLeaks('left pad helper', async () => {
itLeaks('should prepend padding', async () => {
describe('left pad helper', async () => {
it('should prepend padding', async () => {
expect(leftPad('test')).to.equal('0000test');
});
itLeaks('should return long strings as-is', async () => {
it('should return long strings as-is', async () => {
const long = 'testing-words';
expect(leftPad(long, 8)).to.equal(long);
});
itLeaks('should use padding string', async () => {
it('should use padding string', async () => {
expect(leftPad('test', 8, 'too')).to.equal('toottest', 'must repeat and truncate the padding string');
});
});
describeLeaks('trim helper', async () => {
itLeaks('should return strings shorter than max', async () => {
describe('trim helper', async () => {
it('should return strings shorter than max', async () => {
expect(trim('yes', 5)).to.equal('yes', 'shorter than max');
expect(trim(TEST_SHORT, 5)).to.equal(TEST_SHORT, 'equal to max');
});
itLeaks('should trim strings longer than max', async () => {
it('should trim strings longer than max', async () => {
expect(trim(TEST_LONG, 3, '...')).to.equal('...');
expect(trim(TEST_LONG, 5)).to.equal('he...');
expect(trim(TEST_LONG, 8)).to.equal('hello...');
});
itLeaks('should not add tail when max is small', async () => {
it('should not add tail when max is small', async () => {
expect(trim(TEST_SHORT, 2, '...')).to.equal('he');
expect(trim(TEST_LONG, 5, 'very long tail')).to.equal(TEST_SHORT);
expect(trim(TEST_SHORT, 8, 'very long tail')).to.equal(TEST_SHORT);

View File

@ -1,62 +1,61 @@
import { expect } from 'chai';
import { ensureArray, hasItems } from '../../src';
import { NotFoundError } from '../../src/error/NotFoundError';
import { countOf, defaultWhen, filterNil, mustCoalesce, mustFind } from '../../src/Maybe';
import { describeLeaks, itLeaks } from '../helpers/async';
import { hasItems, ensureArray } from '../../src';
describeLeaks('utils', async () => {
describeLeaks('count list', async () => {
itLeaks('should count a single item', async () => {
describe('utils', async () => {
describe('count list', async () => {
it('should count a single item', async () => {
expect(countOf(1)).to.equal(1, 'numbers');
expect(countOf('')).to.equal(1, 'empty strings');
expect(countOf('123')).to.equal(1, 'other strings');
});
itLeaks('should count an array of items', async () => {
it('should count an array of items', async () => {
expect(countOf([1])).to.equal(1, 'single item list');
expect(countOf([1, 2, 3])).to.equal(3, 'multi item list');
});
itLeaks('should count an unknown argument as 0', async () => {
it('should count an unknown argument as 0', async () => {
expect(countOf(undefined)).to.equal(0, 'undefined');
// eslint-disable-next-line no-null/no-null
expect(countOf(null)).to.equal(0, 'null');
});
});
describeLeaks('filter nil', async () => {
itLeaks('should remove nil items', async () => {
describe('filter nil', async () => {
it('should remove nil items', async () => {
// eslint-disable-next-line no-null/no-null
expect(filterNil([1, undefined, 2, null, 3])).to.deep.equal([1, 2, 3]);
});
});
describeLeaks('must find helper', async () => {
itLeaks('should return matching item', async () => {
describe('must find helper', async () => {
it('should return matching item', async () => {
expect(mustFind([1, 2, 3], (val) => (val % 2) === 0)).to.equal(2);
});
itLeaks('should throw if no item matches', async () => {
it('should throw if no item matches', async () => {
expect(() => {
mustFind([1, 2, 3], (val) => val === 4);
}).to.throw(NotFoundError);
});
});
describeLeaks('default when', async () => {
itLeaks('should return the first item when the condition is true', async () => {
describe('default when', async () => {
it('should return the first item when the condition is true', async () => {
expect(defaultWhen(true, 1, 2)).to.equal(1);
});
itLeaks('should return the second item otherwise', async () => {
it('should return the second item otherwise', async () => {
expect(defaultWhen(false, 1, 2)).to.equal(2);
});
});
describeLeaks('must coalesce helper', async () => {
describe('must coalesce helper', async () => {
/* eslint-disable no-null/no-null */
itLeaks('should return the first existent value', async () => {
it('should return the first existent value', async () => {
expect(mustCoalesce(null, null, 3, null)).to.equal(3);
expect(mustCoalesce(null, null, undefined, 'string')).to.equal('string');
expect(mustCoalesce(null, undefined, [], null)).to.deep.equal([]);
@ -64,19 +63,19 @@ describeLeaks('utils', async () => {
/* eslint-enable no-null/no-null */
});
describeLeaks('has items helper', async () => {
itLeaks('should return false for non-array values', async () => {
describe('has items helper', async () => {
it('should return false for non-array values', async () => {
/* eslint-disable @typescript-eslint/no-explicit-any */
expect(hasItems({} as any)).to.equal(false);
expect(hasItems(4 as any)).to.equal(false);
/* eslint-enable @typescript-eslint/no-explicit-any */
});
itLeaks('should return false for empty arrays', async () => {
it('should return false for empty arrays', async () => {
expect(hasItems([])).to.equal(false);
});
itLeaks('should return true for arrays with elements', async () => {
it('should return true for arrays with elements', async () => {
expect(hasItems([
true,
false,
@ -84,14 +83,14 @@ describeLeaks('utils', async () => {
});
});
describeLeaks('ensure array helper', async () => {
itLeaks('should convert nil values to arrays', async () => {
describe('ensure array helper', async () => {
it('should convert nil values to arrays', async () => {
/* eslint-disable-next-line no-null/no-null */
expect(ensureArray(null)).to.deep.equal([]);
expect(ensureArray(undefined)).to.deep.equal([]);
});
itLeaks('should copy arrays', async () => {
it('should copy arrays', async () => {
const data: Array<number> = [];
const copy = ensureArray(data);