Skip to content

Commit

Permalink
[New] add t.capture and t.captureFn, modeled after tap
Browse files Browse the repository at this point in the history
  • Loading branch information
ljharb committed Sep 20, 2023
1 parent 135a952 commit 9e21f7a
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 1 deletion.
71 changes: 71 additions & 0 deletions lib/test.js
Expand Up @@ -15,6 +15,7 @@ var inspect = require('object-inspect');
var is = require('object-is');
var objectKeys = require('object-keys');
var every = require('array.prototype.every');
var mockProperty = require('mock-property');

var isEnumerable = callBound('Object.prototype.propertyIsEnumerable');
var toLowerCase = callBound('String.prototype.toLowerCase');
Expand All @@ -26,6 +27,7 @@ var $replace = callBound('String.prototype.replace');
var $strSlice = callBound('String.prototype.slice');
var $push = callBound('Array.prototype.push');
var $shift = callBound('Array.prototype.shift');
var $slice = callBound('Array.prototype.slice');

var nextTick = typeof setImmediate !== 'undefined'
? setImmediate
Expand Down Expand Up @@ -204,6 +206,75 @@ Test.prototype.teardown = function teardown(fn) {
}
};

function wrapFunction(original) {
if (typeof original !== 'undefined' && typeof original !== 'function') {
throw new TypeError('`original` must be a function or `undefined`');
}

var bound = original && callBind.apply(original);

var calls = [];

var wrapObject = {
__proto__: null,
wrapped: function wrapped() {
var args = $slice(arguments);
var completed = false;
try {
var returned = original ? bound(this, arguments) : void undefined;
$push(calls, { args: args, receiver: this, returned: returned });
completed = true;
return returned;
} finally {
if (!completed) {
$push(calls, { args: args, receiver: this, threw: true });
}
}
},
calls: calls,
results: function results() {
try {
return calls;
} finally {
calls = [];
wrapObject.calls = calls;
}
}
};
return wrapObject;
}

Test.prototype.capture = function capture(obj, method) {
if (!obj || (typeof obj !== 'object' && typeof obj !== 'function')) {
throw new TypeError('`obj` must be an object');
}
if (typeof method !== 'string' && typeof method !== 'symbol') {
throw new TypeError('`method` must be a string or a symbol');
}
var implementation = arguments.length > 2 ? arguments[2] : void undefined;
if (typeof implementation !== 'undefined' && typeof implementation !== 'function') {
throw new TypeError('`implementation`, if provided, must be a function');
}

var wrapper = wrapFunction(implementation);
var restore = mockProperty(obj, method, { value: wrapper.wrapped });
this.teardown(restore);

wrapper.results.restore = restore;

return wrapper.results;
};

Test.prototype.captureFn = function captureFn(original) {
if (typeof original !== 'function') {
throw new TypeError('`original` must be a function');
}

var wrapObject = wrapFunction(original);
wrapObject.wrapped.calls = wrapObject.calls;
return wrapObject.wrapped;
};

Test.prototype._end = function _end(err) {
var self = this;

Expand Down
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -40,6 +40,7 @@
"inherits": "^2.0.4",
"is-regex": "^1.1.4",
"minimist": "^1.2.8",
"mock-property": "^1.0.0",
"object-inspect": "^1.12.3",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
Expand All @@ -58,6 +59,7 @@
"es-value-fixtures": "^1.4.2",
"eslint": "=8.8.0",
"falafel": "^2.2.5",
"intl-fallback-symbol": "^1.0.0",
"jackspeak": "=2.1.1",
"js-yaml": "^3.14.0",
"npm-run-posix-or-windows": "^2.0.2",
Expand Down
26 changes: 25 additions & 1 deletion readme.markdown
Expand Up @@ -384,7 +384,8 @@ You may pass the same options that [`test()`](#testname-opts-cb) accepts.

## t.comment(message)

Print a message without breaking the tap output. (Useful when using e.g. `tap-colorize` where output is buffered & `console.log` will print in incorrect order vis-a-vis tap output.)
Print a message without breaking the tap output.
(Useful when using e.g. `tap-colorize` where output is buffered & `console.log` will print in incorrect order vis-a-vis tap output.)

Multiline output will be split by `\n` characters, and each one printed as a comment.

Expand All @@ -396,6 +397,29 @@ Assert that `string` matches the RegExp `regexp`. Will fail when the first two a

Assert that `string` does not match the RegExp `regexp`. Will fail when the first two arguments are the wrong type.

## t.capture(obj, method, implementation = () => {})

Replaces `obj[method]` with the supplied implementation.
`obj` must be a non-primitive, `method` must be a valid property key (string or symbol), and `implementation`, if provided, must be a function.

Calling the returned `results()` function will return an array of call result objects.
The array of calls will be reset whenever the function is called.
Call result objects will match one of these forms:
- `{ args: [x, y, z], receiver: o, returned: a }`
- `{ args: [x, y, z], receiver: o, threw: true }`

The replacement will automatically be restored on test teardown.
You can restore it manually, if desired, by calling `.restore()` on the returned results function.

Modeled after [tap](https://tapjs.github.io/tapjs/modules/_tapjs_intercept.html).

## t.captureFn(original)

Wraps the supplied function.
The returned wrapper has a `.calls` property, which is an array that will be populated with call result objects, described under `t.capture()`.

Modeled after [tap](https://tapjs.github.io/tapjs/modules/_tapjs_intercept.html).

## var htest = test.createHarness()

Create a new test harness instance, which is a function like `test()`, but with a new pending stack and test state.
Expand Down
132 changes: 132 additions & 0 deletions test/capture.js
@@ -0,0 +1,132 @@
'use strict';

var tape = require('../');
var tap = require('tap');
var concat = require('concat-stream');
var inspect = require('object-inspect');
var forEach = require('for-each');
var v = require('es-value-fixtures');

var stripFullStack = require('./common').stripFullStack;

tap.test('capture: output', function (tt) {
tt.plan(1);

var test = tape.createHarness();
var count = 0;
test.createStream().pipe(concat(function (body) {
tt.same(stripFullStack(body.toString('utf8')), [].concat(
'TAP version 13',
'# argument validation',
v.primitives.map(function (x) {
return 'ok ' + ++count + ' ' + inspect(x) + ' is not an Object';
}),
v.nonPropertyKeys.map(function (x) {
return 'ok ' + ++count + ' ' + inspect(x) + ' is not a valid property key';
}),
v.nonFunctions.filter(function (x) { return typeof x !== 'undefined'; }).map(function (x) {
return 'ok ' + ++count + ' ' + inspect(x) + ' is not a function';
}),
'# captures calls',
'ok ' + ++count + ' property has expected initial value',
'# capturing',
'ok ' + ++count + ' throwing implementation throws',
'ok ' + ++count + ' should be deeply equivalent',
'ok ' + ++count + ' should be deeply equivalent',
'ok ' + ++count + ' should be deeply equivalent',
'ok ' + ++count + ' should be deeply equivalent',
'ok ' + ++count + ' should be deeply equivalent',
'# post-capturing',
'ok ' + ++count + ' property is restored',
'ok ' + ++count + ' added property is removed',
'',
'1..' + count,
'# tests ' + count,
'# pass ' + count,
'',
'# ok',
''
));
}));

test('argument validation', function (t) {
forEach(v.primitives, function (primitive) {
t.throws(
function () { t.capture(primitive, ''); },
TypeError,
inspect(primitive) + ' is not an Object'
);
});

forEach(v.nonPropertyKeys, function (nonPropertyKey) {
t.throws(
function () { t.capture({}, nonPropertyKey); },
TypeError,
inspect(nonPropertyKey) + ' is not a valid property key'
);
});

forEach(v.nonFunctions, function (nonFunction) {
if (typeof nonFunction !== 'undefined') {
t.throws(
function () { t.capture({}, '', nonFunction); },
TypeError,
inspect(nonFunction) + ' is not a function'
);
}
});

t.end();
});

test('captures calls', function (t) {
var sentinel = { sentinel: true, inspect: function () { return '{ SENTINEL OBJECT }'; } };
var o = { foo: sentinel, inspect: function () { return '{ o OBJECT }'; } };
t.equal(o.foo, sentinel, 'property has expected initial value');

t.test('capturing', function (st) {
var results = st.capture(o, 'foo', function () { return sentinel; });
var results2 = st.capture(o, 'foo2');
var up = new SyntaxError('foo');
var resultsThrow = st.capture(o, 'fooThrow', function () { throw up; });

o.foo(1, 2, 3);
o.foo(3, 4, 5);
o.foo2.call(sentinel, 1);
st.throws(
function () { o.fooThrow(1, 2, 3); },
SyntaxError,
'throwing implementation throws'
);

st.deepEqual(results(), [
{ args: [1, 2, 3], receiver: o, returned: sentinel },
{ args: [3, 4, 5], receiver: o, returned: sentinel }
]);
st.deepEqual(results(), []);

o.foo(6, 7, 8);
st.deepEqual(results(), [
{ args: [6, 7, 8], receiver: o, returned: sentinel }
]);

st.deepEqual(results2(), [
{ args: [1], receiver: sentinel, returned: undefined }
]);
st.deepEqual(resultsThrow(), [
{ args: [1, 2, 3], receiver: o, threw: true }
]);

st.end();
});

t.test('post-capturing', function (st) {
st.equal(o.foo, sentinel, 'property is restored');
st.notOk('foo2' in o, 'added property is removed');

st.end();
});

t.end();
});
});
75 changes: 75 additions & 0 deletions test/captureFn.js
@@ -0,0 +1,75 @@
'use strict';

var tape = require('../');
var tap = require('tap');
var concat = require('concat-stream');
var inspect = require('object-inspect');
var forEach = require('for-each');
var v = require('es-value-fixtures');

var stripFullStack = require('./common').stripFullStack;

tap.test('captureFn: output', function (tt) {
tt.plan(1);

var test = tape.createHarness();
var count = 0;
test.createStream().pipe(concat(function (body) {
tt.same(stripFullStack(body.toString('utf8')), [].concat(
'TAP version 13',
'# argument validation',
v.nonFunctions.map(function (x) {
return 'ok ' + ++count + ' ' + inspect(x) + ' is not a function';
}),
'# captured fn calls',
'ok ' + ++count + ' return value is passed through',
'ok ' + ++count + ' throwing implementation throws',
'ok ' + ++count + ' should be deeply equivalent',
'ok ' + ++count + ' should be deeply equivalent',
'',
'1..' + count,
'# tests ' + count,
'# pass ' + count,
'',
'# ok',
''
));
}));

test('argument validation', function (t) {
forEach(v.nonFunctions, function (nonFunction) {
t.throws(
function () { t.captureFn(nonFunction); },
TypeError,
inspect(nonFunction) + ' is not a function'
);
});

t.end();
});

test('captured fn calls', function (t) {
var sentinel = { sentinel: true, inspect: function () { return '{ SENTINEL OBJECT }'; } };

var wrappedSentinelThunk = t.captureFn(function () { return sentinel; });
var up = new SyntaxError('foo');
var wrappedThrower = t.captureFn(function () { throw up; });

t.equal(wrappedSentinelThunk(1, 2), sentinel, 'return value is passed through');
t.throws(
function () { wrappedThrower.call(sentinel, 1, 2, 3); },
SyntaxError,
'throwing implementation throws'
);

t.deepEqual(wrappedSentinelThunk.calls, [
{ args: [1, 2], receiver: undefined, returned: sentinel }
]);

t.deepEqual(wrappedThrower.calls, [
{ args: [1, 2, 3], receiver: sentinel, threw: true }
]);

t.end();
});
});

0 comments on commit 9e21f7a

Please sign in to comment.