UNPKG

tap-parser

Version:

parse the test anything protocol

914 lines 32.7 kB
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