'use strict'
// Transforms a stream of TAP into a stream of result objects
// and string comments. Emits "results" event with summary.
const MiniPass = require('minipass')
// this isn't for performance or anything, it just confuses vim's
// brace-matching to have these in the middle of functions, and
// i'm too lazy to dig into vim-javascript to fix it.
const OPEN_BRACE_EOL = /\{\s*$/
const SPACE_OPEN_BRACE_EOL = / \{$/
// used by the Parser.parse() method
const etoa = require('events-to-array')
// used by Parser.stringify() and Parser.parse() in flattening mode
const getId = () => {
const id = () => id.current++
id.current = 1
return id
}
const yaml = require('tap-yaml')
// every line outside of a yaml block is one of these things, or
// a comment, or garbage.
const lineTypes = {
testPoint: /^(not )?ok(?: ([0-9]+))?(?:(?: -)?( .*?))?(\{?)\n$/,
pragma: /^pragma ([+-])([a-zA-Z0-9_-]+)\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$/
}
const lineType = line => {
for (let t in lineTypes) {
const match = line.match(lineTypes[t])
if (match)
return [t, match]
}
return null
}
const parseDirective = line => {
if (!line.trim())
return false
line = line.replace(OPEN_BRACE_EOL, '').trim()
const time = line.match(/^time=((?:[1-9][0-9]*|0)(?:\.[0-9]+)?)(ms|s)$/i)
if (time) {
let 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 ]
}
const type = line.match(/^(todo|skip)(?:\S*)\b(.*)$/i)
if (!type)
return false
return [
type[1].toLowerCase(),
type[2].trim() || true,
]
}
class Result {
constructor (parsed, count) {
const ok = !parsed[1]
const id = +(parsed[2] || 0)
let buffered = parsed[4]
this.ok = ok
if (parsed[2])
this.id = id
let rest = parsed[3] || ''
let name
// We know at this point the parsed result cannot contain \n,
// so we can leverage that as a placeholder.
// first, replace any PAIR of \ chars with \n
// then, split on any # that is not preceeded by \
// the first of these is definitely the description
// the rest is the directive, if recognized, otherwise
// we just lump it onto the description, but escaped.
// then any \n chars in either are turned into \ (just one)
// escape \ with \
rest = rest.replace(/(\\\\)/g, '\n')
rest = rest.split(/(?<=\s|^)(?<!\\)#/g)
name = rest.shift().replace(/\\#/g, '#').replace(/\n/g, '\\')
rest = rest.join('#').replace(/\\#/g, '#').replace(/\n/g, '\\')
// now, let's see if there's a directive in there.
const 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
const dirKey = dir[0]
const dirValue = dir[1]
this[dirKey] = dirValue
}
if (OPEN_BRACE_EOL.test(name)) {
name = name.replace(OPEN_BRACE_EOL, '')
buffered = '{'
}
if (buffered === '{')
this.buffered = true
if (name)
this.name = name.trim()
}
}
class Parser extends MiniPass {
static parse (str, options = {}) {
const { flat = false } = options
const ignore = [
'pipe',
'unpipe',
'prefinish',
'finish',
'line',
'pass',
'fail',
'todo',
'skip',
'result',
]
if (flat)
ignore.push('assert', 'child', 'plan', 'complete')
const parser = new Parser(options)
const events = etoa(parser, ignore)
if (flat) {
const id = getId()
parser.on('result', res => {
const name = []
if (res.fullname)
name.push(res.fullname)
if (res.name)
name.push(res.name)
res.name = name.join(' > ').trim()
res.fullname = ''
res.id = id()
events.push(['assert', res])
})
parser.on('complete', res => {
if (!res.bailout)
events.push(['plan', { end: id.current - 1, start: 1 }])
events.push(['complete', res])
})
}
parser.end(str)
return events
}
static stringify (msg, { flat = false, indent = '', id = getId() } = {}) {
const ind = flat ? '' : indent
return ind + msg.map(item => {
switch (item[0]) {
case 'child':
const comment = item[1][0]
const child = item[1].slice(1)
return Parser.stringify([comment], { flat, indent: '', id }) +
Parser.stringify(child, { flat, indent: ' ', id })
case 'version':
return 'TAP version ' + item[1] + '\n'
case 'plan':
if (flat) {
if (indent !== '')
return ''
item[1].start = 1
item[1].end = id.current - 1
}
return item[1].start + '..' + item[1].end
+ (item[1].comment ? ' # ' + esc(item[1].comment) : '') + '\n'
case 'pragma':
return 'pragma ' + (item[2] ? '+' : '-') + item[1] + '\n'
case 'bailout':
return 'Bail out!' + (item[1] ? (' ' + esc(item[1])) : '') + '\n'
case 'assert':
const res = item[1]
if (flat) {
res.id = id()
const name = []
if (res.fullname)
name.push(res.fullname)
if (res.name)
name.push(res.name)
res.name = name.join(' > ').trim()
}
return (res.ok ? '' : 'not ') + 'ok' +
(res.id !== undefined ? ' ' + res.id : '' ) +
(res.name
? ' - ' + esc(res.name).replace(SPACE_OPEN_BRACE_EOL, '')
: '') +
(res.skip ? ' # SKIP' +
(res.skip === true ? '' : ' ' + esc(res.skip)) : '') +
(res.todo ? ' # TODO' +
(res.todo === true ? '' : ' ' + esc(res.todo)) : '') +
(res.time ? ' # time=' + res.time + 'ms' : '') +
'\n' +
(res.diag ?
' ---\n ' +
yaml.stringify(res.diag).split('\n').join('\n ').trim() +
'\n ...\n'
: '')
case 'extra':
case 'comment':
return item[1]
}
}).join('').split('\n').join('\n' + ind).trim() + '\n'
}
constructor (options, onComplete) {
if (typeof options === 'function') {
onComplete = options
options = {}
}
options = options || {}
super(options)
this.resume()
if (onComplete)
this.on('complete', onComplete)
this.time = null
this.name = options.name || ''
this.comments = []
this.results = null
this.braceLevel = null
this.parent = options.parent || null
this.closingTestPoint = this.parent && options.closingTestPoint
this.root = options.parent ? this.parent.root : this
this.failures = []
if (options.passes)
this.passes = []
this.level = options.level || 0
this.pointsSeen = new Map()
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.previousChild = 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
}
get fullname () {
return ((this.parent ? this.parent.fullname + ' ' : '') +
(this.name || '')).trim()
}
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.count)
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 = { start: this.planStart, end: this.planEnd }
this.tapError(res)
}
}
if (resId && this.pointsSeen.has(res.id)) {
res.tapError = 'test point id ' + resId + ' appears multiple times'
res.previous = this.pointsSeen.get(res.id)
this.tapError(res)
} 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) {
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)
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(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) {
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 = { 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) {
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 = { start, end }
this.tapError(res)
}
}
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 () {
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
}
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
if (typeof encoding === 'string' && encoding !== 'utf8')
chunk = Buffer.from(chunk, encoding)
if (Buffer.isBuffer(chunk))
chunk += ''
if (typeof encoding === 'function') {
cb = encoding
encoding = null
}
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) {
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) {
this.syntheticBailout = false
let reason = this.bailedOut
if (reason === true)
reason = ''
else
reason = ' ' + reason
this.emit('line', 'Bail out!' + reason + '\n')
}
let 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
if (this.buffered) {
this.planStart = 1
this.planEnd = 0
} else
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)
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)
}
}
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.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) {
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 (let c = 0; c < this.extraQueue.length; c++) {
this.emit(this.extraQueue[c][0], this.extraQueue[c][1])
}
this.extraQueue.length = 0
}
endChild () {
if (this.child && (!this.bailingOut || this.child.count)) {
if (this.child.closingTestPoint)
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++
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.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)
const 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,
closingTestPoint: maybeBuffered && this.current,
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 = null
} else if (maybeBuffered) {
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)
}
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).trimRight()
} catch (er) {}
}
let y
if (dump)
y = ' ---\n ' + dump.split('\n').join('\n ') + '\n ...\n'
else
y = '\n'
let 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
}
let ind = '' // new Array(this.level + 1).join(' ')
message = message.replace(/[\n\r\s\t]/g, ' ')
let point = '\nnot ok ' + n + ' - ' + message + '\n' + y
if (this.planEnd === -1)
point += '1..' + n + '\n'
this.write(point)
this.aborted = true
this.end()
}
emitAssert (res) {
res.fullname = this.fullname
this.emit('assert', res)
// see if we need to surface to the top level
if (this.child || this.previousChild) {
const c = this.child || this.previousChild
this.previousChild = null
if (res.name === c.name &&
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, 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)
const dir = parseDirective(line.replace(/^\s*#\s*/, '').trim())
if (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]*/)[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
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') {
this.bailout(unesc(type[1][1].trim()), false)
return
}
if (type[0] === 'pragma') {
const pragma = type[1]
this.pragma(pragma[2], pragma[1] === '+', line)
return
}
if (type[0] === 'version') {
const version = type[1]
this.version(parseInt(version[1], 10), line)
return
}
if (type[0] === 'plan') {
const plan = type[1]
this.plan(+plan[1], +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.
/* 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])
}
}
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[2].charAt(0) !== ' ') {
// integer number of indentations.
const 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)
}
}
// turn \ into \\ and # into \#, for stringifying back to TAP
const esc = str => str
.replace(/\\/g, '\\\\')
.replace(/#/g, '\\#')
.trim()
const unesc = str => str
.replace(/(\\\\)/g, '\u0000')
.replace(/\\#/g, '#')
.replace(/\u0000/g, '\\')
.trim()
class FinalResults {
constructor (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
this.time = self.time
if (self.passes)
this.passes = self.passes
}
}
class FinalPlan {
constructor (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 || ''
}
}
module.exports = Parser
|