tap-parser
Version:
parse the test anything protocol
914 lines • 32.7 kB
JavaScript
import { EventEmitter } from 'events';
import yaml from 'tap-yaml';
import { FinalResults } from './final-results.js';
import { lineType, lineTypes } from './line-type.js';
import { parseDirective } from './parse-directive.js';
import { Plan } from './plan.js';
import { Result } from './result.js';
export { FinalResults } from './final-results.js';
export { lineType, lineTypes } from './line-type.js';
export { parseDirective } from './parse-directive.js';
export { Plan } from './plan.js';
export { Result } from './result.js';
import { parse, stringify } from './statics.js';
import { unesc } from './escape.js';
// TODO: declare event signatures
export class Parser extends EventEmitter {
#child = null;
#current = null;
#extraQueue = [];
#maybeChild = null;
#postPlan = false;
#previousChild = null;
#yamlish = '';
#yind = '';
#sawVersion = false;
aborted = false;
bail = false;
bailedOut = false;
#bailingOut = false;
braceLevel = 0;
buffer = '';
buffered;
#closingTestPoint = null;
comments = [];
count = 0;
fail = 0;
failures = [];
skips = [];
todos = [];
level;
name;
ok = true;
omitVersion;
parent = null;
pass = 0;
passes;
planComment = '';
planEnd = -1;
planStart = -1;
pointsSeen = new Map();
pragmas;
preserveWhitespace;
results = null;
root;
skip = 0;
strict;
syntheticBailout = false;
syntheticPlan = false;
time = null;
todo = 0;
get closingTestPoint() {
return this.#closingTestPoint;
}
set closingTestPoint(res) {
this.#closingTestPoint = res;
if (res)
res.closingTestPoint = true;
}
/* c8 ignore start */
get readable() {
return false;
}
get writable() {
return true;
}
constructor(options, onComplete) {
if (typeof options === 'function') {
onComplete = options;
options = {};
}
options = options || {};
super();
if (onComplete)
this.on('complete', onComplete);
this.name = options.name || '';
this.parent = options.parent || null;
this.closingTestPoint =
(this.parent && options.closingTestPoint) || null;
this.root = options.parent ? options.parent.root : this;
this.passes = options.passes ? [] : null;
this.level = options.level || 0;
this.bail = !!options.bail;
this.omitVersion = !!options.omitVersion;
this.buffered = !!options.buffered;
this.preserveWhitespace = options.preserveWhitespace || false;
this.strict = !!options.strict;
this.pragmas = { strict: this.strict };
}
get fullname() {
const n = [];
const pn = (this.parent?.fullname ?? '').trim();
const mn = (this.name || '').trim();
if (pn)
n.push(pn);
if (mn)
n.push(mn);
return n.join(' > ');
}
tapError(error, line) {
if (line)
this.emit('line', line);
this.ok = false;
this.fail++;
if (typeof error === 'string') {
error = {
tapError: error,
};
}
this.failures.push(error);
}
parseTestPoint(testPoint, line) {
// need to hold off on this when we have a child so we can
// associate the closing test point with the test.
if (!this.#child)
this.emitResult();
if (this.bailedOut)
return;
const resId = testPoint[2];
const res = new Result(testPoint, this);
if (resId && this.planStart !== -1) {
const lessThanStart = res.id < this.planStart;
const greaterThanEnd = res.id > this.planEnd;
if (lessThanStart || greaterThanEnd) {
if (lessThanStart)
res.tapError = 'id less than plan start';
else
res.tapError = 'id greater than plan end';
res.plan = new Plan(this.planStart, this.planEnd);
this.tapError(res, line);
}
}
if (resId && this.pointsSeen.has(res.id)) {
res.tapError =
'test point id ' + resId + ' appears multiple times';
/* c8 ignore start */
res.previous = this.pointsSeen.get(res.id) || null;
/* c8 ignore stop */
this.tapError(res, line);
}
else if (resId) {
this.pointsSeen.set(res.id, res);
}
if (this.#child) {
if (!this.#child.closingTestPoint)
this.#child.closingTestPoint = res;
this.emitResult();
// can only bail out here in the case of a child with broken diags
// anything else would have bailed out already.
if (this.bailedOut)
return;
}
this.emit('line', line);
if (!res.skip && !res.todo)
this.ok = this.ok && res.ok;
// hold onto it, because we might get yamlish diagnostics
this.#current = res;
}
nonTap(data, didLine = false) {
if (this.#bailingOut && /^( {4})*\}\n$/.test(data))
return;
if (this.strict) {
const err = {
tapError: 'Non-TAP data encountered in strict mode',
data: data,
};
this.tapError(err, data);
if (this.parent)
this.parent.tapError(err, data);
}
// emit each line, then the extra as a whole
if (!didLine)
data
.split('\n')
.slice(0, -1)
.forEach(line => {
line += '\n';
if (this.#current || this.#extraQueue.length)
this.#extraQueue.push(['line', line]);
else
this.emit('line', line);
});
this.emitExtra(data);
}
emitExtra(data, fromChild = false) {
if (this.parent)
this.parent.emitExtra(data.replace(/\n$/, '').replace(/^/gm, ' ') + '\n', true);
else if (!fromChild && (this.#current || this.#extraQueue.length))
this.#extraQueue.push(['extra', data]);
else
this.emit('extra', data);
}
plan(start, end, comment, line) {
// not allowed to have more than one plan
if (this.planStart !== -1) {
this.nonTap(line);
return;
}
// can't put a plan in a child.
if (this.#child || this.#yind) {
this.nonTap(line);
return;
}
this.emitResult();
if (this.bailedOut)
return;
// 1..0 is a special case. Otherwise, end must be >= start
if (end < start && end !== 0 && start !== 1) {
if (this.strict)
this.tapError({
tapError: 'plan end cannot be less than plan start',
plan: { start, end },
}, line);
else
this.nonTap(line);
return;
}
this.planStart = start;
this.planEnd = end;
const p = new Plan(start, end, comment);
if (p.comment)
this.planComment = p.comment = comment;
// This means that the plan is coming at the END of all the tests
// Plans MUST be either at the beginning or the very end. We treat
// plans like '1..0' the same, since they indicate that no tests
// will be coming.
if (this.count !== 0 || this.planEnd === 0) {
const seen = new Set();
for (const [id, res] of this.pointsSeen.entries()) {
const tapError = id < start ? 'id less than plan start'
: id > end ? 'id greater than plan end'
: null;
if (tapError) {
seen.add(tapError);
res.tapError = tapError;
res.plan = new Plan(start, end);
this.tapError(res, line);
}
}
this.#postPlan = true;
}
this.emit('line', line);
this.emit('plan', p);
}
resetYamlish() {
this.#yind = '';
this.#yamlish = '';
}
// that moment when you realize it's not what you thought it was
yamlGarbage() {
const yamlGarbage = this.#yind + '---\n' + this.#yamlish;
this.emitResult();
if (this.bailedOut)
return;
this.nonTap(yamlGarbage, true);
}
yamlishLine(line) {
if (line === this.#yind + '...\n') {
// end the yaml block
this.processYamlish();
}
else {
this.#yamlish += line;
}
}
processYamlish() {
/* c8 ignore start */
if (!this.#current) {
throw new Error('called processYamlish without a current test point');
}
/* c8 ignore stop */
const yamlish = this.#yamlish;
this.resetYamlish();
let diags;
try {
diags = yaml.parse(yamlish);
}
catch (er) {
this.nonTap(this.#yind + '---\n' + yamlish + this.#yind + '...\n', true);
return;
}
if (typeof diags.duration_ms === 'number' &&
this.#current.time === null) {
this.#current.time = diags.duration_ms;
delete diags.duration_ms;
}
this.#current.diag = diags;
// we still don't emit the result here yet, to support diags
// that come ahead of buffered subtests.
}
write(chunk, encoding, cb) {
if (this.aborted) {
return false;
}
if (typeof encoding === 'string' &&
encoding !== 'utf8' &&
typeof chunk === 'string') {
chunk = Buffer.from(chunk, encoding);
}
if (Buffer.isBuffer(chunk)) {
chunk = chunk.toString('utf8');
}
if (typeof encoding === 'function') {
cb = encoding;
encoding = undefined;
}
this.buffer += chunk;
do {
const match = this.buffer.match(/^.*\r?\n/);
if (!match)
break;
this.buffer = this.buffer.substring(match[0].length);
this.parse(match[0]);
} while (this.buffer.length);
if (cb)
process.nextTick(cb);
return true;
}
end(chunk, encoding, cb) {
if (chunk && typeof chunk !== 'function') {
if (typeof encoding === 'function') {
cb = encoding;
this.write(chunk);
}
else {
this.write(chunk, encoding);
}
}
if (this.buffer) {
this.write('\n');
}
// if we have yamlish, means we didn't finish with a ...
if (this.#yamlish)
this.yamlGarbage();
this.emitResult();
if (this.syntheticBailout && this.level === 0) {
this.syntheticBailout = false;
const reason = this.bailedOut === true ? '' : ' ' + this.bailedOut;
this.emit('line', 'Bail out!' + reason + '\n');
}
let skipAll = false;
if (this.planEnd === 0 && this.planStart === 1) {
skipAll = true;
if (this.count === 0) {
this.ok = true;
}
else {
this.tapError('Plan of 1..0, but test points encountered', '');
}
}
else if (!this.bailedOut && this.planStart === -1) {
if (this.count === 0 && !this.syntheticPlan) {
this.syntheticPlan = true;
if (this.buffered) {
this.planStart = 1;
this.planEnd = 0;
}
else {
if (!this.#sawVersion) {
this.version(14, 'TAP version 14\n');
}
this.plan(1, 0, 'no tests found', '1..0 # no tests found\n');
}
skipAll = true;
}
else {
this.tapError('no plan', '');
}
}
else if (this.ok &&
this.count !== this.planEnd - this.planStart + 1) {
this.tapError('incorrect number of tests', '');
}
this.emitComplete(skipAll);
if (cb)
process.nextTick(cb);
return this;
}
emitComplete(skipAll) {
if (!this.results) {
const res = (this.results = new FinalResults(!!skipAll, this));
if (!res.bailout) {
// comment a bit at the end so we know what happened.
// but don't repeat these comments if they're already present.
if (res.plan.end !== res.count) {
this.emitComment('test count(' +
res.count +
') != plan(' +
res.plan.end +
')', false, true);
}
}
this.emit('complete', this.results);
this.emit('finish');
this.emit('close');
}
}
version(version, line) {
// If version is specified, must be at the very beginning.
if (version >= 13 &&
this.planStart === -1 &&
this.count === 0 &&
!this.#current) {
this.#sawVersion = true;
this.emit('line', line);
this.emit('version', version);
}
else
this.nonTap(line);
}
pragma(key, value, line) {
// can't put a pragma in a child or yaml block
if (this.#child) {
this.nonTap(line);
return;
}
this.emitResult();
if (this.bailedOut)
return;
// only the 'strict' pragma is currently relevant
if (key === 'strict') {
this.strict = value;
}
this.pragmas[key] = value;
this.emit('line', line);
this.emit('pragma', key, value);
}
bailout(reason, synthetic = false) {
this.syntheticBailout = synthetic;
if (this.#bailingOut)
return;
// Guard because emitting a result can trigger a forced bailout
// if the harness decides that failures should be bailouts.
this.#bailingOut = reason || true;
if (!synthetic)
this.emitResult();
else
this.#current = null;
this.bailedOut = this.#bailingOut;
this.ok = false;
if (!synthetic) {
// synthetic bailouts get emitted on end
let line = 'Bail out!';
if (reason)
line += ' ' + reason;
this.emit('line', line + '\n');
}
this.emit('bailout', reason);
if (this.parent) {
this.end();
this.parent.bailout(reason, true);
}
}
clearExtraQueue() {
for (const [ev, data] of this.#extraQueue) {
this.emit(ev, data);
}
this.#extraQueue.length = 0;
}
endChild() {
if (this.#child && (!this.#bailingOut || this.#child.count)) {
this.#child.time = this.#child.closingTestPoint?.time || null;
this.#previousChild = this.#child;
this.#child.end();
this.#child = null;
}
}
emitResult() {
if (this.bailedOut)
return;
this.endChild();
this.resetYamlish();
if (!this.#current)
return this.clearExtraQueue();
const res = this.#current;
this.#current = null;
this.count++;
const { skip, todo } = res;
if (skip)
this.skips.push({ ...res, skip });
if (todo)
this.todos.push({ ...res, todo });
if (res.ok) {
this.pass++;
if (!skip && !todo && this.passes)
this.passes.push(res);
}
else {
this.fail++;
if (!res.todo && !res.skip) {
this.ok = false;
this.failures.push(res);
}
}
if (res.skip)
this.skip++;
if (res.todo)
this.todo++;
this.emitAssert(res);
if (this.bail &&
!res.ok &&
!res.todo &&
!res.skip &&
!this.#bailingOut) {
this.#maybeChild = null;
const ind = new Array(this.level + 1).join(' ');
let p;
for (p = this; p.parent; p = p.parent)
;
const bailName = res.name ? ' ' + res.name : '';
p.parse(ind + 'Bail out!' + bailName + '\n');
}
this.clearExtraQueue();
}
// TODO: We COULD say that any "relevant tap" line that's indented
// by 4 spaces starts a child test, and just call it 'unnamed' if
// it does not have a prefix comment. In that case, any number of
// 4-space indents can be plucked off to try to find a relevant
// TAP line type, and if so, start the unnamed child.
startChild(line) {
const maybeBuffered = this.#current && this.#current.buffered;
const unindentStream = !maybeBuffered && this.#maybeChild;
const indentStream = !maybeBuffered &&
!unindentStream &&
lineTypes.subtestIndent.test(line);
// If we have any other result waiting in the wings, we need to emit
// that now. A buffered test emits its test point at the *end* of
// the child subtest block, so as to match streamed test semantics.
if (!maybeBuffered)
this.emitResult();
if (this.bailedOut)
return;
this.#child = new Parser({
bail: this.bail,
parent: this,
level: this.level + 1,
buffered: maybeBuffered || undefined,
closingTestPoint: (maybeBuffered && this.#current) || undefined,
preserveWhitespace: this.preserveWhitespace,
omitVersion: true,
strict: this.strict,
});
this.#child.on('complete', results => {
if (!results.ok)
this.ok = false;
});
this.#child.on('line', l => {
if (l.trim() || this.preserveWhitespace)
l = ' ' + l;
this.emit('line', l);
});
// Canonicalize the parsing result of any kind of subtest
// if it's a buffered subtest or a non-indented Subtest directive,
// then synthetically emit the Subtest comment
line = line.substring(4);
let subtestComment;
if (indentStream) {
subtestComment = line;
line = '';
}
else if (maybeBuffered && this.#current) {
subtestComment = '# Subtest: ' + this.#current.name + '\n';
}
else {
subtestComment = this.#maybeChild || '# Subtest\n';
}
this.#maybeChild = null;
this.#child.name = subtestComment
.substring('# Subtest: '.length)
.trim();
// at some point, we may wish to move 100% to preferring
// the Subtest comment on the parent level. If so, uncomment
// this line, and remove the child.emitComment below.
// this.emit('comment', subtestComment)
if (!this.#child.buffered)
this.emit('line', subtestComment);
this.emit('child', this.#child);
this.#child.emitComment(subtestComment, true);
if (line)
this.#child.parse(line);
}
destroy(er) {
this.abort('destroyed', er);
}
abort(message = '', extra) {
if (this.#child) {
const b = this.#child.buffered;
this.#child.abort(message, extra);
extra = null;
if (b)
this.write('\n}\n');
}
let dump = '';
if (extra && Object.keys(extra).length) {
try {
dump = yaml.stringify(extra).trimEnd();
/* c8 ignore start */
}
catch (er) { }
/* c8 ignore stop */
}
const y = dump ?
' ---\n ' + dump.split('\n').join('\n ') + '\n ...\n'
: '\n';
const n = (this.count || 0) + 1 + (this.#current ? 1 : 0);
if (this.planEnd !== -1 && this.planEnd < n && this.parent) {
// skip it, let the parent do this.
this.aborted = true;
return;
}
message = message.replace(/[\n\r\s\t]/g, ' ');
let point = '\nnot ok ' + n + ' - ' + message + '\n' + y;
if (this.planEnd === -1)
point += '1..' + n + '\n';
if (!this.#sawVersion)
this.write('TAP version 14\n');
this.write(point);
this.aborted = true;
this.end();
}
emitAssert(res) {
this.emit('assert', res);
// see if we need to surface to the top level
const c = this.#child || this.#previousChild;
if (c) {
this.#previousChild = null;
if (res.name === c.name &&
c.results &&
res.ok === c.results.ok &&
c.results.count &&
!res.todo &&
!res.skip) {
// just procedural, ignore it
return;
}
}
// surface result to the top level parser
this.root.emit('result', res);
if (res.skip)
this.root.emit('skip', res);
else if (res.todo)
this.root.emit('todo', res);
else if (!res.ok)
this.root.emit('fail', res);
else
this.root.emit('pass', res);
}
emitComment(line, skipLine = false, noDuplicate = false) {
if (line.trim().charAt(0) !== '#')
line = '# ' + line;
if (line.slice(-1) !== '\n')
line += '\n';
// XXX: is this still needed?
/* c8 ignore start */
if (noDuplicate && this.comments.indexOf(line) !== -1)
return;
/* c8 ignore stop */
this.comments.push(line);
const dir = parseDirective(line.replace(/^\s*#\s*/, '').trim());
if (dir && dir[0] === 'time' && typeof dir[1] === 'number')
this.time = dir[1];
if (this.#current || this.#extraQueue.length) {
// no way to get here with skipLine being true
this.#extraQueue.push(['line', line]);
this.#extraQueue.push(['comment', line]);
}
else {
if (!skipLine)
this.emit('line', line);
this.emit('comment', line);
}
}
parse(line) {
// normalize line endings
line = line.replace(/\r\n$/, '\n');
// sometimes empty lines get trimmed, but are still part of
// a subtest or a yaml block. Otherwise, nothing to parse!
if (line === '\n') {
if (this.#child)
line = ' ' + line;
else if (this.#yind)
line = this.#yind + line;
}
// If we're bailing out, then the only thing we want to see is the
// end of a buffered child test. Anything else should be ignored.
// But! if we're bailing out a nested child, and ANOTHER nested child
// comes after that one, then we don't want the second child's } to
// also show up, or it looks weird.
if (this.#bailingOut) {
if (!/^\s*}\n$/.test(line))
return;
else if (!this.braceLevel || line.length < this.braceLevel)
this.braceLevel = line.length;
else
return;
}
// This allows omitting even parsing the version if the test is
// an indented child test. Several parsers get upset when they
// see an indented version field.
if (this.omitVersion &&
lineTypes.version.test(line) &&
!this.#yind) {
return;
}
// check to see if the line is indented.
// if it is, then it's either a subtest, yaml, or garbage.
const indent = line.match(/^[ \t]*/);
if (indent && indent[0]) {
this.parseIndent(line, indent[0]);
return;
}
// In any case where we're going to emitResult, that can trigger
// a bailout, so we need to only emit the line once we know that
// isn't happening, to prevent cases where there's a bailout, and
// then one more line of output. That'll also prevent the case
// where the test point is emitted AFTER the line that follows it.
// buffered subtests must end with a }
if (this.#child && this.#child.buffered && line === '}\n') {
this.endChild();
this.emit('line', line);
this.emitResult();
return;
}
// just a \n, emit only if we care about whitespace
const validLine = this.preserveWhitespace || line.trim() || this.#yind;
if (line === '\n')
return validLine && this.emit('line', line);
// buffered subtest with diagnostics
if (this.#current &&
line === '{\n' &&
this.#current.diag &&
!this.#current.buffered &&
!this.#child) {
this.emit('line', line);
this.#current.buffered = true;
return;
}
// now we know it's not indented, so if it's either valid tap
// or garbage. Get the type of line.
const type = lineType(line);
if (!type) {
this.nonTap(line);
return;
}
if (type[0] === 'comment') {
this.emitComment(line);
return;
}
// if we have any yamlish, it's garbage now. We tolerate non-TAP and
// comments in the midst of yaml (though, perhaps, that's questionable
// behavior), but any actual TAP means that the yaml block was just
// not valid.
if (this.#yind)
this.yamlGarbage();
// If it's anything other than a comment or garbage, then any
// maybeChild is just an unsatisfied promise.
if (this.#maybeChild) {
this.emitComment(this.#maybeChild);
this.#maybeChild = null;
}
// nothing but comments can come after a trailing plan
if (this.#postPlan) {
this.nonTap(line);
return;
}
// ok, now it's maybe a thing
if (type[0] === 'bailout') {
const msg = type[1]?.[1] || '';
this.bailout(unesc(msg), false);
return;
}
if (type[0] === 'pragma') {
const [_, posNeg, key] = type[1];
this.pragma(String(key), posNeg === '+', line);
return;
}
if (type[0] === 'version') {
const version = type[1];
this.version(parseInt(String(version[1]), 10), line);
return;
}
if (type[0] === 'plan') {
const plan = type[1];
this.plan(+String(plan[1]), +String(plan[2]), unesc(plan[3] || '').trim(), line);
return;
}
// streamed subtests will end when this test point is emitted
if (type[0] === 'testPoint') {
// note: it's weird, but possible, to have a testpoint ending in
// { before a streamed subtest which ends with a test point
// instead of a }. In this case, the parser gets confused, but
// also, even beginning to handle that means doing a much more
// involved multi-line parse. By that point, the subtest block
// has already been emitted as a 'child' event, so it's too late
// to really do the optimal thing. The only way around would be
// to buffer up everything and do a multi-line parse. This is
// rare and weird, and a multi-line parse would be a bigger
// rewrite, so I'm allowing it as it currently is.
this.parseTestPoint(type[1], line);
return;
}
// We already detected nontap up above, so the only case left
// should be a `# Subtest:` comment. Ignore for coverage, but
// include the error here just for good measure.
if (type[0] === 'subtest') {
// this is potentially a subtest. Not indented.
// hold until later.
this.#maybeChild = line;
/* c8 ignore start */
}
else {
throw new Error('Unhandled case: ' + type[0]);
}
/* c8 ignore stop */
}
parseIndent(line, indent) {
// still belongs to the child, so pass it along.
if (this.#child && line.substring(0, 4) === ' ') {
line = line.substring(4);
this.#child.write(line);
return;
}
// one of:
// - continuing yaml block
// - starting yaml block
// - ending yaml block
// - body of a new child subtest that was previously introduced
// - An indented subtest directive
// - A comment, or garbage
// continuing/ending yaml block
if (this.#yind) {
if (line.indexOf(this.#yind) === 0) {
this.emit('line', line);
this.yamlishLine(line);
return;
}
else {
// oops! that was not actually yamlish, I guess.
// this is a case where the indent is shortened mid-yamlish block
// treat existing yaml as garbage, continue parsing this line
this.yamlGarbage();
}
}
// start a yaml block under a test point
if (this.#current && !this.#yind && line === indent + '---\n') {
this.#yind = indent;
this.emit('line', line);
return;
}
// at this point, not yamlish, and not an existing child test.
// We may have already seen an unindented Subtest directive, or
// a test point that ended in { indicating a buffered subtest
// Child tests are always indented 4 spaces.
if (line.substring(0, 4) === ' ') {
if (this.#maybeChild ||
(this.#current && this.#current.buffered) ||
lineTypes.subtestIndent.test(line)) {
this.startChild(line);
return;
}
// It's _something_ indented, if the indentation is divisible by
// 4 spaces, and the result is actual TAP of some sort, then do
// a child subtest for it as well.
//
// This will lead to some ambiguity in cases where there are multiple
// levels of non-signaled subtests, but a Subtest comment in the
// middle of them, which may or may not be considered "indented"
// See the subtest-no-comment-mid-comment fixture for an example
// of this. As it happens, the preference is towards an indented
// Subtest comment as the interpretation, which is the only possible
// way to resolve this, since otherwise there's no way to distinguish
// between an anonymous subtest with a non-indented Subtest comment,
// and an indented Subtest comment.
const s = line.match(/( {4})+(.*\n)$/);
if (s && s[2]?.charAt(0) !== ' ') {
// integer number of indentations.
const type = lineType(String(s[2]));
if (type) {
if (type[0] === 'comment') {
this.emit('line', line);
this.emitComment(line);
}
else {
// it's relevant! start as an "unnamed" child subtest
this.startChild(line);
}
return;
}
}
}
// at this point, it's either a non-subtest comment, or garbage.
if (lineTypes.comment.test(line)) {
this.emitComment(line);
return;
}
this.nonTap(line);
}
static parse(str, options = {}) {
return parse(str, options);
}
static stringify(msg, options = {}) {
return stringify(msg, options);
}
}
//# sourceMappingURL=index.js.map