Skip to content

Commit

Permalink
Add support for asynchronous assertions through logging
Browse files Browse the repository at this point in the history
  • Loading branch information
Avaq committed Jun 15, 2019
1 parent db28686 commit 8d01a55
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 54 deletions.
171 changes: 124 additions & 47 deletions lib/doctest.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,14 @@ module.exports = function(path, options) {
console.log (source.replace (/\n$/, ''));
return Promise.resolve ([]);
} else if (options.silent) {
return Promise.resolve (evaluate (options.module, source, path));
return evaluate (options.module, source, path);
} else {
console.log ('running doctests in ' + path + '...');
var results = evaluate (options.module, source, path);
log (results);
return Promise.resolve (results);
return (evaluate (options.module, source, path))
.then (function(results) {
log (results);
return results;
});
}
};

Expand Down Expand Up @@ -165,6 +167,8 @@ var OPEN = 'open';
var INPUT = 'input';
var OUTPUT = 'output';

var MATCH_LOG = /^\[([a-zA-Z]+)\]:/;

// normalizeTest :: { output :: { value :: String } } -> Undefined
function normalizeTest($test) {
var $output = $test[OUTPUT];
Expand Down Expand Up @@ -201,16 +205,36 @@ function processLine(
accum.tests.push ($test = {});
$test[accum.state = INPUT] = {value: value};
input ($test);
} else if (trimmedLine.charAt (0) === '.') {
} else if (accum.state === INPUT && trimmedLine.charAt (0) === '.') {
value = stripLeading (1, ' ', stripLeading (Infinity, '.', trimmedLine));
$test = accum.tests[accum.tests.length - 1];
$test[INPUT].value += '\n' + value;
appendToInput ($test);
} else if (accum.state === OUTPUT && trimmedLine.charAt (0) === '.') {
value = stripLeading (1, ' ', stripLeading (Infinity, '.', trimmedLine));
$test = accum.tests[accum.tests.length - 1];
$test[accum.state].value += '\n' + value;
(accum.state === INPUT ? appendToInput : appendToOutput) ($test);
} else if (accum.state === INPUT) {
$test[OUTPUT][$test[OUTPUT].length - 1].value += '\n' + value;
appendToOutput ($test);
} else if (MATCH_LOG.test (trimmedLine)) {
value = stripLeading (1, ' ', trimmedLine.replace (MATCH_LOG, ''));
$test = accum.tests[accum.tests.length - 1];
($test[accum.state = OUTPUT] = $test[accum.state] || []).push ({
channel: MATCH_LOG.exec (trimmedLine)[1],
value: value
});
if ($test[OUTPUT].length === 1) {
output ($test);
}
} else {
value = trimmedLine;
$test = accum.tests[accum.tests.length - 1];
$test[accum.state = OUTPUT] = {value: value};
output ($test);
($test[accum.state = OUTPUT] = $test[accum.state] || []).push ({
channel: null,
value: value
});
if ($test[OUTPUT].length === 1) {
output ($test);
}
}
}
}
Expand Down Expand Up @@ -322,6 +346,7 @@ function wrap$js(test, sourceType) {
type === 'ImportDeclaration' ||
type === 'VariableDeclaration' ?
test[INPUT].value :
// TODO: Why are we enqueing two things if it could just be one structure?
[
'__doctest.enqueue({',
' type: "' + INPUT + '",',
Expand All @@ -335,7 +360,9 @@ function wrap$js(test, sourceType) {
' ":": ' + test[OUTPUT].loc.start.line + ',',
' "!": ' + test['!'] + ',',
' thunk: function() {',
' return ' + test[OUTPUT].value + ';',
' return ' + test[OUTPUT].map (function(out) {
return '{channel: "' + out.channel + '", value: ' + out.value + '}';
}) + ';',
' }',
'});'
]).join ('\n');
Expand Down Expand Up @@ -526,47 +553,97 @@ function commonjsEval(source, path) {
return run (queue);
}

function run(queue) {
return queue.reduce (function(accum, io) {
var thunk = accum.thunk;
if (io.type === INPUT) {
if (thunk != null) thunk ();
accum.thunk = io.thunk;
} else if (io.type === OUTPUT) {
var either;
function run(queue, optionalLogMediator) {
var logMediator = optionalLogMediator || {};
return queue.reduce (function(p, io) {
return p.then (function(accum) {
var thunk = accum.thunk;

if (io.type === INPUT) {
if (thunk != null) thunk ();
accum.thunk = io.thunk;
return accum;
}

var errored;
var expecteds = io.thunk ();
var outputs = [];

accum.thunk = null;

logMediator.emit = function(channel, value) {
outputs.push ({channel: channel, value: value});
};

try {
either = {tag: 'Right', value: thunk ()};
outputs.push ({channel: null, value: thunk ()});
errored = false;
} catch (err) {
either = {tag: 'Left', value: err};
outputs.push ({channel: null, value: err});
errored = true;
}
accum.thunk = null;
var expected = io.thunk ();

var pass, repr;
if (either.tag === 'Left') {
var name = either.value.name;
var message = either.value.message;
pass = io['!'] &&
name === expected.name &&
message === expected.message.replace (/^$/, message);
repr = '! ' + name +
(expected.message && message.replace (/^(?!$)/, ': '));
} else {
pass = !io['!'] && Z.equals (either.value, expected);
repr = show (either.value);

function awaitOutput() {
return Promise (function(res) {
// TODO: Pass down options to get logTimeout
var t = setTimeout (done, 100);
logMediator.emit = function(channel, value) {
outputs.push ({channel: channel, value: value});
clearTimeout (t);
t = setTimeout (done, 100);
};
function done() {
logMediator.emit = function() {};
clearTimeout (t);
res ();
}
});
}

accum.results.push ([
pass,
repr,
io['!'] ?
'! ' + expected.name + expected.message.replace (/^(?!$)/, ': ') :
show (expected),
io[':']
]);
}
return accum;
}, {results: [], thunk: null}).results;
// TODO: Don't care to await output if a log function was not provided
return (awaitOutput ()).then (verifyOutput);

function verifyOutput() {
if (outputs.length < expecteds.length) {
// Fail because not enough output was generated
} else if (outputs.length > expecteds.length) {
// Fail because too much output was generated
} else {
outputs.forEach (function(output, idx) {
var expected = expecteds[idx];
var pass, repr;

if (output.channel !== expected.channel) {
// Fail because output was given on the wrong channel
} else if (errored && output.channel == null) {
var name = output.value.name;
var message = output.value.message;
pass = io['!'] &&
name === expected.value.name &&
message === expected.value.message.replace (/^$/, message);
repr = '! ' + name +
(expected.value.message && message.replace (/^(?!$)/, ': '));
} else {
pass = !io['!'] && Z.equals (output.value, expected.value);
repr = show (output.value);
}

accum.results.push ([
pass,
repr,
io['!'] ?
'! ' + expected.name + expected.message.replace (/^(?!$)/, ': ') :
show (expected),
io[':']
]);
});
}
}

});
}, Promise.resolve ({results: [], thunk: null})).then (function(reduced) {
return reduced.results;
});
}

module.exports.run = run;
Expand Down
23 changes: 16 additions & 7 deletions lib/doctest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export default async function(path, options) {
common.sanitizeFileContents (
await util.promisify (fs.readFile) (path, 'utf8')
)
)
),
options.logFunction
);

if (options.print) {
Expand All @@ -39,15 +40,20 @@ export default async function(path, options) {
}
}

function wrap(source) {
function wrap(source, logFunction) {
return common.unlines ([
'export const __doctest = {',
' queue: [],',
' enqueue: function(io) { this.queue.push(io); }',
' enqueue: function(io) { this.queue.push(io); },',
' logMediator: {emit: function(){}}',
'};',
'',
source
]);
''
]) + (logFunction != null ? common.unlines ([
'const ' + logFunction + ' = channel => value => {',
' __doctest.logMediator.emit ({channel, value});',
'};',
''
]) : []) + (source);
}

function evaluate(source, path) {
Expand All @@ -64,7 +70,10 @@ function evaluate(source, path) {

return (util.promisify (fs.writeFile) (abspath, source))
.then (function() { return import (abspath); })
.then (function(module) { return doctest.run (module.__doctest.queue); })
.then (function(module) {
return doctest.run (module.__doctest.queue,
module.__doctest.logMediator);
})
.then (cleanup (Promise.resolve.bind (Promise)),
cleanup (Promise.reject.bind (Promise)));
}
5 changes: 5 additions & 0 deletions lib/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ program
'specify line preceding doctest block (e.g. "```javascript")')
.option (' --closing-delimiter <delimiter>',
'specify line following doctest block (e.g. "```")')
.option (' --log-function <name>',
'enable log output assertions')
.option (' --log-timeout <milliseconds>',
'specify an alternative log timeout time (defaults to 100)',
100)
.option ('-p, --print',
'output the rewritten source without running tests')
.option ('-s, --silent',
Expand Down

0 comments on commit 8d01a55

Please sign in to comment.