From 3d96d6945ea1cda7780fb3fc6bc04c275ace594a Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Wed, 20 Sep 2023 13:47:51 -0700 Subject: [PATCH] [New] add `t.capture` and `t.captureFn`, modeled after tap --- lib/test.js | 74 ++++++++++++++++++++++++++ package.json | 1 + readme.markdown | 26 ++++++++- test/capture.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++ test/captureFn.js | 75 ++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 test/capture.js create mode 100644 test/captureFn.js diff --git a/lib/test.js b/lib/test.js index 90bbedf6..6e5f2672 100644 --- a/lib/test.js +++ b/lib/test.js @@ -8,13 +8,18 @@ var EventEmitter = require('events').EventEmitter; var has = require('has'); var isRegExp = require('is-regex'); var trim = require('string.prototype.trim'); +var callBind = require('call-bind'); var callBound = require('call-bind/callBound'); var forEach = require('for-each'); var inspect = require('object-inspect'); +var mockProperty = require('mock-property'); + var isEnumerable = callBound('Object.prototype.propertyIsEnumerable'); var toLowerCase = callBound('String.prototype.toLowerCase'); var $exec = callBound('RegExp.prototype.exec'); var objectToString = callBound('Object.prototype.toString'); +var $push = callBound('Array.prototype.push'); +var $slice = callBound('Array.prototype.slice'); var nextTick = typeof setImmediate !== 'undefined' ? setImmediate @@ -171,6 +176,75 @@ Test.prototype.teardown = function (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 (err) { var self = this; if (this._progeny.length) { diff --git a/package.json b/package.json index e8d7504e..777ed6b3 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "inherits": "~2.0.4", "is-regex": "~1.1.4", "minimist": "~1.2.8", + "mock-property": "~1.0.0", "object-inspect": "~1.12.3", "resolve": "~1.22.6", "string.prototype.trim": "~1.2.8" diff --git a/readme.markdown b/readme.markdown index 22ad29e8..8f773989 100644 --- a/readme.markdown +++ b/readme.markdown @@ -360,7 +360,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. @@ -372,6 +373,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, thrown: e }` + +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. diff --git a/test/capture.js b/test/capture.js new file mode 100644 index 00000000..0fee3782 --- /dev/null +++ b/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 equivalent', + 'ok ' + ++count + ' should be equivalent', + 'ok ' + ++count + ' should be equivalent', + 'ok ' + ++count + ' should be equivalent', + 'ok ' + ++count + ' should be 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(); + }); +}); diff --git a/test/captureFn.js b/test/captureFn.js new file mode 100644 index 00000000..45b58a5b --- /dev/null +++ b/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 equivalent', + 'ok ' + ++count + ' should be 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(); + }); +});