// Transforms a stream of TAP into a stream of result objects // and string comments. Emits "results" event with summary. var Writable = require('stream').Writable /* istanbul ignore if */ if (!Writable) { try { Writable = require('readable-stream').Writable } catch (er) { throw new Error('Please install "readable-stream" to use this module ' + 'with Node.js v0.8 and before') } } var yaml = require('js-yaml') var util = require('util') var assert = require('assert') util.inherits(Parser, Writable) module.exports = Parser // every line outside of a yaml block is one of these things, or // a comment, or garbage. var lineTypes = { testPoint: /^(not )?ok(?: ([0-9]+))?(?:(?: -)?( .*?))?(\{?)\n$/, pragma: /^pragma ([+-])([a-z]+)\n$/, bailout: /^bail out!(.*)\n$/i, version: /^TAP version ([0-9]+)\n$/i, childVersion: /^( )+TAP version ([0-9]+)\n$/i, plan: /^([0-9]+)\.\.([0-9]+)(?:\s+(?:#\s*(.*)))?\n$/, subtest: /^# Subtest(?:: (.*))?\n$/, subtestIndent: /^ # Subtest(?:: (.*))?\n$/, comment: /^\s*#.*\n$/ } var lineTypeNames = Object.keys(lineTypes) function lineType (line) { for (var t in lineTypes) { var match = line.match(lineTypes[t]) if (match) return [t, match] } return null } function parseDirective (line) { if (!line.trim()) return false line = line.replace(/\{\s*$/, '').trim() var time = line.match(/^time=((?:[1-9][0-9]*|0)(?:\.[0-9]+)?)(ms|s)$/i) if (time) { var n = +time[1] if (time[2] === 's') { // JS does weird things with floats. Round it off a bit. n *= 1000000 n = Math.round(n) n /= 1000 } return [ 'time', n ] } var type = line.match(/^(todo|skip)\b/i) if (!type) return false return [ type[1].toLowerCase(), line.substr(type[1].length).trim() || true ] } function Result (parsed, count) { var ok = !parsed[1] var id = +(parsed[2] || count + 1) var buffered = parsed[4] this.ok = ok this.id = id var rest = parsed[3] || '' var name rest = rest.replace(/([^\\]|^)((?:\\\\)*)#/g, '$1\n$2').split('\n') name = rest.shift() rest = rest.filter(function (r) { return r.trim() }).join('#') // now, let's see if there's a directive in there. var dir = parseDirective(rest.trim()) if (!dir) name += (rest ? '#' + rest : '') + buffered else { // handle buffered subtests with todo/skip on them, like // ok 1 - bar # todo foo {\n var dirKey = dir[0] var dirValue = dir[1] this[dirKey] = dirValue } if (/\{\s*$/.test(name)) { name = name.replace(/\{\s*$/, '') buffered = '{' } if (buffered === '{') this.buffered = true if (name) this.name = name.trim() return this } function Parser (options, onComplete) { if (typeof options === 'function') { onComplete = options options = {} } if (!(this instanceof Parser)) return new Parser(options, onComplete) options = options || {} if (onComplete) this.on('complete', onComplete) this.comments = [] this.results = null this.braceLevel = null this.parent = options.parent || null this.failures = [] if (options.passes) this.passes = [] this.level = options.level || 0 Writable.call(this) this.buffer = '' this.bail = !!options.bail this.bailingOut = false this.bailedOut = false this.syntheticBailout = false this.syntheticPlan = false this.omitVersion = !!options.omitVersion this.planStart = -1 this.planEnd = -1 this.planComment = '' this.yamlish = '' this.yind = '' this.child = null this.current = null this.maybeSubtest = null this.extraQueue = [] this.buffered = options.buffered || null this.aborted = false this.preserveWhitespace = options.preserveWhitespace || false this.count = 0 this.pass = 0 this.fail = 0 this.todo = 0 this.skip = 0 this.ok = true this.strict = options.strict || false this.pragmas = { strict: this.strict } this.postPlan = false } Parser.prototype.tapError = function (error, line) { if (line) this.emit('line', line) this.ok = false this.fail ++ if (typeof error === 'string') { error = { tapError: error } } this.failures.push(error) } Parser.prototype.parseTestPoint = function (testPoint, line) { this.emitResult() if (this.bailedOut) return this.emit('line', line) var res = new Result(testPoint, this.count) if (this.planStart !== -1) { var lessThanStart = +res.id < this.planStart var 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 = { start: this.planStart, end: this.planEnd } this.tapError(res) } } if (res.id) { if (!this.first || res.id < this.first) this.first = res.id if (!this.last || res.id > this.last) this.last = res.id } if (!res.skip && !res.todo) this.ok = this.ok && res.ok // hold onto it, because we might get yamlish diagnostics this.current = res } Parser.prototype.nonTap = function (data, didLine) { if (this.bailingOut && /^( {4})*\}\n$/.test(data)) return if (this.strict) { var err = { tapError: 'Non-TAP data encountered in strict mode', data: data } this.tapError(err) if (this.parent) this.parent.tapError(err) } // emit each line, then the extra as a whole if (!didLine) data.split('\n').slice(0, -1).forEach(function (line) { line += '\n' if (this.current || this.extraQueue.length) this.extraQueue.push(['line', line]) else this.emit('line', line) }, this) if (this.current || this.extraQueue.length) this.extraQueue.push(['extra', data]) else this.emit('extra', data) } Parser.prototype.plan = function (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: start, end: end } }, line) else this.nonTap(line) return } this.planStart = start this.planEnd = end var p = { start: start, end: end } if (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) this.postPlan = true this.emit('line', line) this.emit('plan', p) } Parser.prototype.resetYamlish = function () { this.yind = '' this.yamlish = '' } // that moment when you realize it's not what you thought it was Parser.prototype.yamlGarbage = function () { var yamlGarbage = this.yind + '---\n' + this.yamlish this.emitResult() if (this.bailedOut) return this.nonTap(yamlGarbage, true) } Parser.prototype.yamlishLine = function (line) { if (line === this.yind + '...\n') { // end the yaml block this.processYamlish() } else { this.yamlish += line } } Parser.prototype.processYamlish = function () { var yamlish = this.yamlish this.resetYamlish() try { var diags = yaml.safeLoad(yamlish) } catch (er) { this.nonTap(this.yind + '---\n' + yamlish + this.yind + '...\n', true) return } this.current.diag = diags // we still don't emit the result here yet, to support diags // that come ahead of buffered subtests. } Parser.prototype.write = function (chunk, encoding, cb) { if (this.aborted) return if (typeof encoding === 'string' && encoding !== 'utf8') chunk = new Buffer(chunk, encoding) if (Buffer.isBuffer(chunk)) chunk += '' if (typeof encoding === 'function') { cb = encoding encoding = null } this.buffer += chunk do { var match = this.buffer.match(/^.*\r?\n/) if (!match) break this.buffer = this.buffer.substr(match[0].length) this.parse(match[0]) } while (this.buffer.length) if (cb) process.nextTick(cb) return true } Parser.prototype.end = function (chunk, encoding, cb) { if (chunk) { if (typeof encoding === 'function') { cb = encoding encoding = null } 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) { var reason = this.bailedOut if (reason === true) reason = '' else reason = ' ' + reason this.emit('line', 'Bail out!' + reason + '\n') } var skipAll 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 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') } if (this.ok && !skipAll && this.first !== this.planStart) { this.tapError('first test id does not match plan start') } if (this.ok && !skipAll && this.last !== this.planEnd) { this.tapError('last test id does not match plan end') } Writable.prototype.end.call(this, null, null, cb) this.emitComplete(skipAll) } Parser.prototype.emitComplete = function (skipAll) { if (!this.results) { var 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) if (res.fail > 0 && !res.ok) this.emitComment('failed ' + res.fail + (res.count > 1 ? ' of ' + res.count + ' tests' : ' test'), false, true) if (res.todo > 0) this.emitComment('todo: ' + res.todo, false, true) if (res.skip > 0) this.emitComment('skip: ' + res.skip, false, true) } this.emit('complete', this.results) } } function FinalResults (skipAll, self) { this.ok = self.ok this.count = self.count this.pass = self.pass this.fail = self.fail || 0 this.bailout = self.bailedOut || false this.todo = self.todo || 0 this.skip = skipAll ? self.count : self.skip || 0 this.plan = new FinalPlan(skipAll, self) this.failures = self.failures if (self.passes) this.passes = self.passes } function FinalPlan (skipAll, self) { this.start = self.planStart === -1 ? null : self.planStart this.end = self.planStart === -1 ? null : self.planEnd this.skipAll = skipAll this.skipReason = skipAll ? self.planComment : '' this.comment = self.planComment || '' } Parser.prototype.version = function (version, line) { // If version is specified, must be at the very beginning. if (version >= 13 && this.planStart === -1 && this.count === 0 && !this.current) { this.emit('line', line) this.emit('version', version) } else this.nonTap(line) } Parser.prototype.pragma = function (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) } Parser.prototype.bailout = function (reason, synthetic) { 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 var 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) } } Parser.prototype.clearExtraQueue = function () { for (var c = 0; c < this.extraQueue.length; c++) { this.emit(this.extraQueue[c][0], this.extraQueue[c][1]) } this.extraQueue.length = 0 } Parser.prototype.endChild = function () { if (this.child) { this.child.end() this.child = null } } Parser.prototype.emitResult = function () { if (this.bailedOut) return this.endChild() this.resetYamlish() if (!this.current) return this.clearExtraQueue() var res = this.current this.current = null this.count++ if (res.ok) { this.pass++ if (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.emit('assert', res) if (this.bail && !res.ok && !res.todo && !res.skip && !this.bailingOut) { this.maybeChild = null var ind = new Array(this.level + 1).join(' ') for (var p = this; p.parent; p = p.parent); var 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. Parser.prototype.startChild = function (line) { var maybeBuffered = this.current && this.current.buffered var unindentStream = !maybeBuffered && this.maybeChild var indentStream = !maybeBuffered && !unindentStream && lineTypes.subtestIndent.test(line) var unnamed = !maybeBuffered && !unindentStream && !indentStream // 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, preserveWhitespace: this.preserveWhitespace, omitVersion: true, strict: this.strict }) var self = this this.child.on('complete', function (results) { if (!results.ok) self.ok = false }) this.child.on('line', function (l) { if (this.syntheticPlan) return if (l.trim() || self.preserveWhitespace) l = ' ' + l self.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.substr(4) var subtestComment if (indentStream) { subtestComment = line line = null } else if (maybeBuffered) { subtestComment = '# Subtest: ' + this.current.name + '\n' } else { subtestComment = this.maybeChild || '# Subtest: (anonymous)\n' } this.maybeChild = null this.child.name = subtestComment.substr('# 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) } Parser.prototype.abort = function (message, extra) { if (this.child) { var b = this.child.buffered this.child.abort(message, extra) extra = null if (b) this.write('\n}\n') } var dump if (extra && Object.keys(extra).length) { try { dump = yaml.safeDump(extra).trimRight() } catch (er) {} } var y if (dump) y = ' ---\n ' + dump.split('\n').join('\n ') + '\n ...\n' else y = '\n' var n = (this.count || 0) + 1 if (this.current) n += 1 if (this.planEnd !== -1 && this.planEnd < n && this.parent) { // skip it, let the parent do this. this.aborted = true return } var ind = '' // new Array(this.level + 1).join(' ') message = message.replace(/[\n\r\s\t]/g, ' ') var point = '\nnot ok ' + n + ' - ' + message + '\n' + y if (this.planEnd === -1) point += '1..' + n + '\n' this.write(point) this.aborted = true this.end() } Parser.prototype.emitComment = function (line, skipLine, noDuplicate) { if (line.trim().charAt(0) !== '#') line = '# ' + line if (line.slice(-1) !== '\n') line += '\n' if (noDuplicate && this.comments.indexOf(line) !== -1) return this.comments.push(line) 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) } } Parser.prototype.parse = function (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. var indent = line.match(/^[ \t]*/)[0] if (indent) { this.parseIndent(line, indent) 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 var 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.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. var 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') { this.bailout(type[1][1].trim(), false) return } if (type[0] === 'pragma') { var pragma = type[1] this.pragma(pragma[2], pragma[1] === '+', line) return } if (type[0] === 'version') { var version = type[1] this.version(parseInt(version[1], 10), line) return } if (type[0] === 'plan') { var plan = type[1] this.plan(+plan[1], +plan[2], (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. /* istanbul ignore else */ if (type[0] === 'subtest') { // this is potentially a subtest. Not indented. // hold until later. this.maybeChild = line } else { throw new Error('Unhandled case: ' + type[0]) } } Parser.prototype.parseIndent = function (line, indent) { // still belongs to the child, so pass it along. if (this.child && line.substr(0, 4) === ' ') { line = line.substr(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.substr(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. var s = line.match(/( {4})+(.*\n)$/) if (s[2].charAt(0) !== ' ') { // integer number of indentations. var type = lineType(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) }