From 5f8ce617ddfa0feb41f52cdcc84e15eb4b59805d Mon Sep 17 00:00:00 2001 From: Wassim Chegham Date: Mon, 21 Nov 2022 23:50:21 +0100 Subject: [PATCH] feat: add initial TAP parser Work in progress PR-URL: https://github.com/nodejs/node/pull/43525 Refs: https://github.com/nodejs/node/issues/43344 Reviewed-By: Franziska Hinkelmann Reviewed-By: Colin Ihrig Reviewed-By: Moshe Atlow (cherry picked from commit f8ce9117b19702487eb600493d941f7876e00e01) --- README.md | 9 +- lib/internal/errors.js | 17 +- lib/internal/per_context/primordials.js | 6 + lib/internal/test_runner/runner.js | 132 +- lib/internal/test_runner/tap_checker.js | 156 ++ lib/internal/test_runner/tap_lexer.js | 523 +++++++ lib/internal/test_runner/tap_parser.js | 981 ++++++++++++ lib/internal/test_runner/tap_stream.js | 34 +- lib/internal/test_runner/test.js | 12 +- test/fixtures/test-runner/invalid-tap.js | 3 + test/fixtures/test-runner/nested.js | 23 + test/parallel/test-runner-cli.js | 54 +- test/parallel/test-runner-run.mjs | 6 +- test/parallel/test-runner-tap-checker.js | 120 ++ test/parallel/test-runner-tap-lexer.js | 447 ++++++ .../parallel/test-runner-tap-parser-stream.js | 631 ++++++++ test/parallel/test-runner-tap-parser.js | 1315 +++++++++++++++++ 17 files changed, 4431 insertions(+), 38 deletions(-) create mode 100644 lib/internal/test_runner/tap_checker.js create mode 100644 lib/internal/test_runner/tap_lexer.js create mode 100644 lib/internal/test_runner/tap_parser.js create mode 100644 test/fixtures/test-runner/invalid-tap.js create mode 100644 test/fixtures/test-runner/nested.js create mode 100644 test/parallel/test-runner-tap-checker.js create mode 100644 test/parallel/test-runner-tap-lexer.js create mode 100644 test/parallel/test-runner-tap-parser-stream.js create mode 100644 test/parallel/test-runner-tap-parser.js diff --git a/README.md b/README.md index 802477f..7ff8afc 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ expected. ```mjs import assert from 'node:assert'; -import { mock, test } from 'node:test'; +import { mock, test } from 'test'; test('spies on a function', () => { const sum = mock.fn((a, b) => { return a + b; @@ -388,7 +388,7 @@ test('spies on a function', () => { ```cjs 'use strict'; const assert = require('node:assert'); -const { mock, test } = require('node:test'); +const { mock, test } = require('test'); test('spies on a function', () => { const sum = mock.fn((a, b) => { return a + b; @@ -964,8 +964,7 @@ Emitted when [`context.diagnostic`][] is called. ### Event: `'test:fail'` * `data` {Object} - * `duration` {number} The test duration. - * `error` {Error} The failure casing test to fail. + * `details` {Object} Additional execution metadata. * `name` {string} The test name. * `testNumber` {number} The ordinal number of the test. * `todo` {string|undefined} Present if [`context.todo`][] is called @@ -976,7 +975,7 @@ Emitted when a test fails. ### Event: `'test:pass'` * `data` {Object} - * `duration` {number} The test duration. + * `details` {Object} Additional execution metadata. * `name` {string} The test name. * `testNumber` {number} The ordinal number of the test. * `todo` {string|undefined} Present if [`context.todo`][] is called diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 8f3a07e..ab98407 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/1aab13cad9c800f4121c1d35b554b78c1b17bdbd/lib/internal/errors.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/errors.js 'use strict' @@ -346,6 +346,21 @@ module.exports = { kIsNodeError } +E('ERR_TAP_LEXER_ERROR', function (errorMsg) { + hideInternalStackFrames(this) + return errorMsg +}, Error) +E('ERR_TAP_PARSER_ERROR', function (errorMsg, details, tokenCausedError, source) { + hideInternalStackFrames(this) + this.cause = tokenCausedError + const { column, line, start, end } = tokenCausedError.location + const errorDetails = `${details} at line ${line}, column ${column} (start ${start}, end ${end})` + return errorMsg + errorDetails +}, SyntaxError) +E('ERR_TAP_VALIDATION_ERROR', function (errorMsg) { + hideInternalStackFrames(this) + return errorMsg +}, Error) E('ERR_TEST_FAILURE', function (error, failureType) { hideInternalStackFrames(this) assert(typeof failureType === 'string', diff --git a/lib/internal/per_context/primordials.js b/lib/internal/per_context/primordials.js index efc6185..f3c0b24 100644 --- a/lib/internal/per_context/primordials.js +++ b/lib/internal/per_context/primordials.js @@ -6,6 +6,7 @@ exports.ArrayFrom = (it, mapFn) => Array.from(it, mapFn) exports.ArrayIsArray = Array.isArray exports.ArrayPrototypeConcat = (arr, ...el) => arr.concat(...el) exports.ArrayPrototypeFilter = (arr, fn) => arr.filter(fn) +exports.ArrayPrototypeFind = (arr, fn) => arr.find(fn) exports.ArrayPrototypeForEach = (arr, fn, thisArg) => arr.forEach(fn, thisArg) exports.ArrayPrototypeIncludes = (arr, el, fromIndex) => arr.includes(el, fromIndex) exports.ArrayPrototypeJoin = (arr, str) => arr.join(str) @@ -17,6 +18,7 @@ exports.ArrayPrototypeShift = arr => arr.shift() exports.ArrayPrototypeSlice = (arr, offset) => arr.slice(offset) exports.ArrayPrototypeSome = (arr, fn) => arr.some(fn) exports.ArrayPrototypeSort = (arr, fn) => arr.sort(fn) +exports.ArrayPrototypeSplice = (arr, offset, len, ...el) => arr.splice(offset, len, ...el) exports.ArrayPrototypeUnshift = (arr, ...el) => arr.unshift(...el) exports.Error = Error exports.ErrorCaptureStackTrace = (...args) => Error.captureStackTrace(...args) @@ -26,6 +28,7 @@ exports.FunctionPrototypeCall = (fn, obj, ...args) => fn.call(obj, ...args) exports.MathMax = (...args) => Math.max(...args) exports.Number = Number exports.NumberIsInteger = Number.isInteger +exports.NumberParseInt = (str, radix) => Number.parseInt(str, radix) exports.NumberMIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER exports.NumberMAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER exports.ObjectAssign = (target, ...sources) => Object.assign(target, ...sources) @@ -56,12 +59,15 @@ exports.SafeWeakSet = WeakSet exports.StringPrototypeEndsWith = (haystack, needle, index) => haystack.endsWith(needle, index) exports.StringPrototypeIncludes = (str, needle) => str.includes(needle) exports.StringPrototypeMatch = (str, reg) => str.match(reg) +exports.StringPrototypeRepeat = (str, times) => str.repeat(times) exports.StringPrototypeReplace = (str, search, replacement) => str.replace(search, replacement) exports.StringPrototypeReplaceAll = replaceAll exports.StringPrototypeStartsWith = (haystack, needle, index) => haystack.startsWith(needle, index) exports.StringPrototypeSlice = (str, ...args) => str.slice(...args) exports.StringPrototypeSplit = (str, search, limit) => str.split(search, limit) +exports.StringPrototypeToUpperCase = str => str.toUpperCase() +exports.StringPrototypeTrim = str => str.trim() exports.Symbol = Symbol exports.SymbolFor = repr => Symbol.for(repr) exports.ReflectApply = (target, self, args) => Reflect.apply(target, self, args) diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 0091563..9b89596 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -1,8 +1,9 @@ -// https://github.com/nodejs/node/blob/9825a7e01d35b9d49ebb58efed2c316012c19db6/lib/internal/test_runner/runner.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/runner.js 'use strict' const { ArrayFrom, ArrayPrototypeFilter, + ArrayPrototypeForEach, ArrayPrototypeIncludes, ArrayPrototypeJoin, ArrayPrototypePush, @@ -11,7 +12,8 @@ const { ObjectAssign, PromisePrototypeThen, SafePromiseAll, - SafeSet + SafeSet, + StringPrototypeRepeat } = require('#internal/per_context/primordials') const { spawn } = require('child_process') @@ -28,7 +30,9 @@ const { validateArray } = require('#internal/validators') const { getInspectPort, isUsingInspector, isInspectorMessage } = require('#internal/util/inspector') const { kEmptyObject } = require('#internal/util') const { createTestTree } = require('#internal/test_runner/harness') -const { kSubtestsFailed, Test } = require('#internal/test_runner/test') +const { kDefaultIndent, kSubtestsFailed, Test } = require('#internal/test_runner/test') +const { TapParser } = require('#internal/test_runner/tap_parser') +const { TokenKind } = require('#internal/test_runner/tap_lexer') const { isSupportedFileType, doesPathMatchFilter @@ -114,16 +118,117 @@ function getRunArgs ({ path, inspectPort }) { return argv } -function runTestFile (path, root, inspectPort) { - const subtest = root.createSubtest(Test, path, async (t) => { +class FileTest extends Test { + #buffer = [] + #handleReportItem ({ kind, node, nesting = 0 }) { + const indent = StringPrototypeRepeat(kDefaultIndent, nesting + 1) + + const details = (diagnostic) => { + return ( + diagnostic && { + __proto__: null, + yaml: + `${indent} ` + + ArrayPrototypeJoin(diagnostic, `\n${indent} `) + + '\n' + } + ) + } + + switch (kind) { + case TokenKind.TAP_VERSION: + // TODO(manekinekko): handle TAP version coming from the parser. + // this.reporter.version(node.version); + break + + case TokenKind.TAP_PLAN: + this.reporter.plan(indent, node.end - node.start + 1) + break + + case TokenKind.TAP_SUBTEST_POINT: + this.reporter.subtest(indent, node.name) + break + + case TokenKind.TAP_TEST_POINT: + // eslint-disable-next-line no-case-declarations + const { todo, skip, pass } = node.status + // eslint-disable-next-line no-case-declarations + let directive + + if (skip) { + directive = this.reporter.getSkip(node.reason) + } else if (todo) { + directive = this.reporter.getTodo(node.reason) + } else { + directive = kEmptyObject + } + + if (pass) { + this.reporter.ok( + indent, + node.id, + node.description, + details(node.diagnostics), + directive + ) + } else { + this.reporter.fail( + indent, + node.id, + node.description, + details(node.diagnostics), + directive + ) + } + break + + case TokenKind.COMMENT: + if (indent === kDefaultIndent) { + // Ignore file top level diagnostics + break + } + this.reporter.diagnostic(indent, node.comment) + break + + case TokenKind.UNKNOWN: + this.reporter.diagnostic(indent, node.value) + break + } + } + + addToReport (ast) { + if (!this.isClearToSend()) { + ArrayPrototypePush(this.#buffer, ast) + return + } + this.reportSubtest() + this.#handleReportItem(ast) + } + + report () { + this.reportSubtest() + ArrayPrototypeForEach(this.#buffer, (ast) => this.#handleReportItem(ast)) + super.report() + } +} + +function runTestFile (path, root, inspectPort, filesWatcher) { + const subtest = root.createSubtest(FileTest, path, async (t) => { const args = getRunArgs({ path, inspectPort }) + const stdio = ['pipe', 'pipe', 'pipe'] + const env = { ...process.env } + if (filesWatcher) { + stdio.push('ipc') + env.WATCH_REPORT_DEPENDENCIES = '1' + } + + const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8', env, stdio }) - const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' }) - // TODO(cjihrig): Implement a TAP parser to read the child's stdout - // instead of just displaying it all if the child fails. let err let stderr = '' + filesWatcher?.watchChildProcessModules(child, path) + child.on('error', (error) => { err = error }) @@ -141,6 +246,17 @@ function runTestFile (path, root, inspectPort) { }) } + const parser = new TapParser() + child.stderr.pipe(parser).on('data', (ast) => { + if (ast.lexeme && isInspectorMessage(ast.lexeme)) { + process.stderr.write(ast.lexeme + '\n') + } + }) + + child.stdout.pipe(parser).on('data', (ast) => { + subtest.addToReport(ast) + }) + const { 0: { 0: code, 1: signal }, 1: stdout } = await SafePromiseAll([ once(child, 'exit', { signal: t.signal }), toArray.call(child.stdout, { signal: t.signal }) diff --git a/lib/internal/test_runner/tap_checker.js b/lib/internal/test_runner/tap_checker.js new file mode 100644 index 0000000..6b25155 --- /dev/null +++ b/lib/internal/test_runner/tap_checker.js @@ -0,0 +1,156 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/tap_checker.js +'use strict' + +const { + ArrayPrototypeFilter, + ArrayPrototypeFind, + NumberParseInt +} = require('#internal/per_context/primordials') +const { + codes: { ERR_TAP_VALIDATION_ERROR } +} = require('#internal/errors') +const { TokenKind } = require('#internal/test_runner/tap_lexer') + +// TODO(@manekinekko): add more validation rules based on the TAP14 spec. +// See https://testanything.org/tap-version-14-specification.html +class TAPValidationStrategy { + validate (ast) { + this.#validateVersion(ast) + this.#validatePlan(ast) + this.#validateTestPoints(ast) + + return true + } + + #validateVersion (ast) { + const entry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_VERSION + ) + + if (!entry) { + throw new ERR_TAP_VALIDATION_ERROR('missing TAP version') + } + + const { version } = entry.node + + // TAP14 specification is compatible with observed behavior of existing TAP13 consumers and producers + if (version !== '14' && version !== '13') { + throw new ERR_TAP_VALIDATION_ERROR('TAP version should be 13 or 14') + } + } + + #validatePlan (ast) { + const entry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_PLAN + ) + + if (!entry) { + throw new ERR_TAP_VALIDATION_ERROR('missing TAP plan') + } + + const plan = entry.node + + if (!plan.start) { + throw new ERR_TAP_VALIDATION_ERROR('missing plan start') + } + + if (!plan.end) { + throw new ERR_TAP_VALIDATION_ERROR('missing plan end') + } + + const planStart = NumberParseInt(plan.start, 10) + const planEnd = NumberParseInt(plan.end, 10) + + if (planEnd !== 0 && planStart > planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `plan start ${planStart} is greater than plan end ${planEnd}` + ) + } + } + + // TODO(@manekinekko): since we are dealing with a flat AST, we need to + // validate test points grouped by their "nesting" level. This is because a set of + // Test points belongs to a TAP document. Each new subtest block creates a new TAP document. + // https://testanything.org/tap-version-14-specification.html#subtests + #validateTestPoints (ast) { + const bailoutEntry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_BAIL_OUT + ) + const planEntry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_PLAN + ) + const testPointEntries = ArrayPrototypeFilter( + ast, + (node) => node.kind === TokenKind.TAP_TEST_POINT + ) + + const plan = planEntry.node + + const planStart = NumberParseInt(plan.start, 10) + const planEnd = NumberParseInt(plan.end, 10) + + if (planEnd === 0 && testPointEntries.length > 0) { + throw new ERR_TAP_VALIDATION_ERROR( + `found ${testPointEntries.length} Test Point${ + testPointEntries.length > 1 ? 's' : '' + } but plan is ${planStart}..0` + ) + } + + if (planEnd > 0) { + if (testPointEntries.length === 0) { + throw new ERR_TAP_VALIDATION_ERROR('missing Test Points') + } + + if (!bailoutEntry && testPointEntries.length !== planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `test Points count ${testPointEntries.length} does not match plan count ${planEnd}` + ) + } + + for (let i = 0; i < testPointEntries.length; i++) { + const test = testPointEntries[i].node + const testId = NumberParseInt(test.id, 10) + + if (testId < planStart || testId > planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `test ${testId} is out of plan range ${planStart}..${planEnd}` + ) + } + } + } + } +} + +// TAP14 and TAP13 are compatible with each other +class TAP13ValidationStrategy extends TAPValidationStrategy {} +class TAP14ValidationStrategy extends TAPValidationStrategy {} + +class TapChecker { + static TAP13 = '13' + static TAP14 = '14' + + constructor ({ specs }) { + switch (specs) { + case TapChecker.TAP13: + this.strategy = new TAP13ValidationStrategy() + break + default: + this.strategy = new TAP14ValidationStrategy() + } + } + + check (ast) { + return this.strategy.validate(ast) + } +} + +module.exports = { + TapChecker, + TAP14ValidationStrategy, + TAP13ValidationStrategy +} diff --git a/lib/internal/test_runner/tap_lexer.js b/lib/internal/test_runner/tap_lexer.js new file mode 100644 index 0000000..9fcf93e --- /dev/null +++ b/lib/internal/test_runner/tap_lexer.js @@ -0,0 +1,523 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/tap_lexer.js +'use strict' + +const { SafeSet, MathMax, StringPrototypeIncludes } = require('#internal/per_context/primordials') +const { + codes: { ERR_TAP_LEXER_ERROR } +} = require('#internal/errors') + +const kEOL = '' +const kEOF = '' + +const TokenKind = { + EOF: 'EOF', + EOL: 'EOL', + NEWLINE: 'NewLine', + NUMERIC: 'Numeric', + LITERAL: 'Literal', + KEYWORD: 'Keyword', + WHITESPACE: 'Whitespace', + COMMENT: 'Comment', + DASH: 'Dash', + PLUS: 'Plus', + HASH: 'Hash', + ESCAPE: 'Escape', + UNKNOWN: 'Unknown', + + // TAP tokens + TAP: 'TAPKeyword', + TAP_VERSION: 'VersionKeyword', + TAP_PLAN: 'PlanKeyword', + TAP_TEST_POINT: 'TestPointKeyword', + TAP_SUBTEST_POINT: 'SubTestPointKeyword', + TAP_TEST_OK: 'TestOkKeyword', + TAP_TEST_NOTOK: 'TestNotOkKeyword', + TAP_YAML_START: 'YamlStartKeyword', + TAP_YAML_END: 'YamlEndKeyword', + TAP_YAML_BLOCK: 'YamlKeyword', + TAP_PRAGMA: 'PragmaKeyword', + TAP_BAIL_OUT: 'BailOutKeyword' +} + +class Token { + constructor ({ kind, value, stream }) { + const valueLength = ('' + value).length + this.kind = kind + this.value = value + this.location = { + line: stream.line, + column: MathMax(stream.column - valueLength + 1, 1), // 1 based + start: MathMax(stream.pos - valueLength, 0), // zero based + end: stream.pos - (value === '' ? 0 : 1) // zero based + } + + // EOF is a special case + if (value === TokenKind.EOF) { + const eofPosition = stream.input.length + 1 // We consider EOF to be outside the stream + this.location.start = eofPosition + this.location.end = eofPosition + this.location.column = stream.column + 1 // 1 based + } + } +} + +class InputStream { + constructor (input) { + this.input = input + this.pos = 0 + this.column = 0 + this.line = 1 + } + + eof () { + return this.peek() === undefined + } + + peek (offset = 0) { + return this.input[this.pos + offset] + } + + next () { + const char = this.peek() + if (char === undefined) { + return undefined + } + + this.pos++ + this.column++ + if (char === '\n') { + this.line++ + this.column = 0 + } + + return char + } +} + +class TapLexer { + static Keywords = new SafeSet([ + 'TAP', + 'version', + 'ok', + 'not', + '...', + '---', + '..', + 'pragma', + '-', + '+' + + // NOTE: "Skip", "Todo" and "Bail out!" literals are deferred to the parser + ]) + + #isComment = false + #source = null + #line = 1 + #column = 0 + #escapeStack = [] + #lastScannedToken = null + + constructor (source) { + this.#source = new InputStream(source) + this.#lastScannedToken = new Token({ + kind: TokenKind.EOL, + value: kEOL, + stream: this.#source + }) + } + + scan () { + const tokens = [] + let chunk = [] + while (!this.eof()) { + const token = this.#scanToken() + + // Remember the last scanned token (except for whitespace) + if (token.kind !== TokenKind.WHITESPACE) { + this.#lastScannedToken = token + } + + if (token.kind === TokenKind.NEWLINE) { + // Store the current chunk + NEWLINE token + tokens.push([...chunk, token]) + chunk = [] + } else { + chunk.push(token) + } + } + + if (chunk.length > 0) { + tokens.push([...chunk, this.#scanEOL()]) + } + + // send EOF as a separate chunk + tokens.push([this.#scanEOF()]) + + return tokens + } + + next () { + return this.#source.next() + } + + eof () { + return this.#source.eof() + } + + error (message, token, expected = '') { + this.#source.error(message, token, expected) + } + + #scanToken () { + const char = this.next() + + if (this.#isEOFSymbol(char)) { + return this.#scanEOF() + } else if (this.#isNewLineSymbol(char)) { + return this.#scanNewLine(char) + } else if (this.#isNumericSymbol(char)) { + return this.#scanNumeric(char) + } else if (this.#isDashSymbol(char)) { + return this.#scanDash(char) + } else if (this.#isPlusSymbol(char)) { + return this.#scanPlus(char) + } else if (this.#isHashSymbol(char)) { + return this.#scanHash(char) + } else if (this.#isEscapeSymbol(char)) { + return this.#scanEscapeSymbol(char) + } else if (this.#isWhitespaceSymbol(char)) { + return this.#scanWhitespace(char) + } else if (this.#isLiteralSymbol(char)) { + return this.#scanLiteral(char) + } + + throw new ERR_TAP_LEXER_ERROR( + `Unexpected character: ${char} at line ${this.#line}, column ${ + this.#column + }` + ) + } + + #scanNewLine (char) { + // In case of odd number of ESCAPE symbols, we need to clear the remaining + // escape chars from the stack and start fresh for the next line. + this.#escapeStack = [] + + // We also need to reset the comment flag + this.#isComment = false + + return new Token({ + kind: TokenKind.NEWLINE, + value: char, + stream: this.#source + }) + } + + #scanEOL () { + return new Token({ + kind: TokenKind.EOL, + value: kEOL, + stream: this.#source + }) + } + + #scanEOF () { + this.#isComment = false + + return new Token({ + kind: TokenKind.EOF, + value: kEOF, + stream: this.#source + }) + } + + #scanEscapeSymbol (char) { + // If the escape symbol has been escaped (by previous symbol), + // or if the next symbol is a whitespace symbol, + // then consume it as a literal. + if ( + this.#hasTheCurrentCharacterBeenEscaped() || + this.#source.peek(1) === TokenKind.WHITESPACE + ) { + this.#escapeStack.pop() + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + // Otherwise, consume the escape symbol as an escape symbol that should be ignored by the parser + // we also need to push the escape symbol to the escape stack + // and consume the next character as a literal (done in the next turn) + this.#escapeStack.push(char) + return new Token({ + kind: TokenKind.ESCAPE, + value: char, + stream: this.#source + }) + } + + #scanWhitespace (char) { + return new Token({ + kind: TokenKind.WHITESPACE, + value: char, + stream: this.#source + }) + } + + #scanDash (char) { + // Peek next 3 characters and check if it's a YAML start marker + const marker = char + this.#source.peek() + this.#source.peek(1) + + if (this.#isYamlStartSymbol(marker)) { + this.next() // consume second - + this.next() // consume third - + + return new Token({ + kind: TokenKind.TAP_YAML_START, + value: marker, + stream: this.#source + }) + } + + return new Token({ + kind: TokenKind.DASH, + value: char, + stream: this.#source + }) + } + + #scanPlus (char) { + return new Token({ + kind: TokenKind.PLUS, + value: char, + stream: this.#source + }) + } + + #scanHash (char) { + const lastCharacter = this.#source.peek(-2) + const nextToken = this.#source.peek() + + // If we encounter a hash symbol at the beginning of a line, + // we consider it as a comment + if (!lastCharacter || this.#isNewLineSymbol(lastCharacter)) { + this.#isComment = true + return new Token({ + kind: TokenKind.COMMENT, + value: char, + stream: this.#source + }) + } + + // The only valid case where a hash symbol is considered as a hash token + // is when it's preceded by a whitespace symbol and followed by a non-hash symbol + if ( + this.#isWhitespaceSymbol(lastCharacter) && + !this.#isHashSymbol(nextToken) + ) { + return new Token({ + kind: TokenKind.HASH, + value: char, + stream: this.#source + }) + } + + const charHasBeenEscaped = this.#hasTheCurrentCharacterBeenEscaped() + if (this.#isComment || charHasBeenEscaped) { + if (charHasBeenEscaped) { + this.#escapeStack.pop() + } + + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + // As a fallback, we consume the hash symbol as a literal + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + #scanLiteral (char) { + let word = char + while (!this.#source.eof()) { + const nextChar = this.#source.peek() + if (this.#isLiteralSymbol(nextChar)) { + word += this.#source.next() + } else { + break + } + } + + word = word.trim() + + if (TapLexer.Keywords.has(word)) { + const token = this.#scanTAPKeyword(word) + if (token) { + return token + } + } + + if (this.#isYamlEndSymbol(word)) { + return new Token({ + kind: TokenKind.TAP_YAML_END, + value: word, + stream: this.#source + }) + } + + return new Token({ + kind: TokenKind.LITERAL, + value: word, + stream: this.#source + }) + } + + #scanTAPKeyword (word) { + const isLastScannedTokenEOLorNewLine = StringPrototypeIncludes( + [TokenKind.EOL, TokenKind.NEWLINE], + this.#lastScannedToken.kind + ) + + if (word === 'TAP' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP, + value: word, + stream: this.#source + }) + } + + if (word === 'version' && this.#lastScannedToken.kind === TokenKind.TAP) { + return new Token({ + kind: TokenKind.TAP_VERSION, + value: word, + stream: this.#source + }) + } + + if (word === '..' && this.#lastScannedToken.kind === TokenKind.NUMERIC) { + return new Token({ + kind: TokenKind.TAP_PLAN, + value: word, + stream: this.#source + }) + } + + if (word === 'not' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP_TEST_NOTOK, + value: word, + stream: this.#source + }) + } + + if ( + word === 'ok' && + (this.#lastScannedToken.kind === TokenKind.TAP_TEST_NOTOK || + isLastScannedTokenEOLorNewLine) + ) { + return new Token({ + kind: TokenKind.TAP_TEST_OK, + value: word, + stream: this.#source + }) + } + + if (word === 'pragma' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP_PRAGMA, + value: word, + stream: this.#source + }) + } + + return null + } + + #scanNumeric (char) { + let number = char + while (!this.#source.eof()) { + const nextChar = this.#source.peek() + if (this.#isNumericSymbol(nextChar)) { + number += nextChar + this.#source.next() + } else { + break + } + } + return new Token({ + kind: TokenKind.NUMERIC, + value: number, + stream: this.#source + }) + } + + #hasTheCurrentCharacterBeenEscaped () { + // Use the escapeStack to keep track of the escape characters + return this.#escapeStack.length > 0 + } + + #isNumericSymbol (char) { + return char >= '0' && char <= '9' + } + + #isLiteralSymbol (char) { + return ( + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + this.#isSpecialCharacterSymbol(char) + ) + } + + #isSpecialCharacterSymbol (char) { + // We deliberately do not include "# \ + -"" in this list + // these are used for comments/reasons explanations, pragma and escape characters + // whitespace is not included because it is handled separately + return '!"$%&\'()*,./:;<=>?@[]^_`{|}~'.indexOf(char) > -1 + } + + #isWhitespaceSymbol (char) { + return char === ' ' || char === '\t' + } + + #isEOFSymbol (char) { + return char === undefined + } + + #isNewLineSymbol (char) { + return char === '\n' || char === '\r' + } + + #isHashSymbol (char) { + return char === '#' + } + + #isDashSymbol (char) { + return char === '-' + } + + #isPlusSymbol (char) { + return char === '+' + } + + #isEscapeSymbol (char) { + return char === '\\' + } + + #isYamlStartSymbol (char) { + return char === '---' + } + + #isYamlEndSymbol (char) { + return char === '...' + } +} + +module.exports = { TapLexer, TokenKind } diff --git a/lib/internal/test_runner/tap_parser.js b/lib/internal/test_runner/tap_parser.js new file mode 100644 index 0000000..6ba3afd --- /dev/null +++ b/lib/internal/test_runner/tap_parser.js @@ -0,0 +1,981 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/tap_parser.js +'use strict' + +const Transform = require('stream').Transform +const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer') +const { TapChecker } = require('#internal/test_runner/tap_checker') +const { + codes: { ERR_TAP_VALIDATION_ERROR, ERR_TAP_PARSER_ERROR } +} = require('#internal/errors') +const { kEmptyObject } = require('#internal/util') +const { + ArrayPrototypeFilter, + ArrayPrototypeForEach, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypeIncludes, + ArrayPrototypeSplice, + Boolean, + Number, + RegExpPrototypeExec, + RegExpPrototypeSymbolReplace, + String, + StringPrototypeTrim, + StringPrototypeSplit +} = require('#internal/per_context/primordials') +/** + * + * TAP14 specifications + * + * See https://testanything.org/tap-version-14-specification.html + * + * Note that the following grammar is intended as a rough "pseudocode" guidance. + * It is not strict EBNF: + * + * TAPDocument := Version Plan Body | Version Body Plan + * Version := "TAP version 14\n" + * Plan := "1.." (Number) (" # " Reason)? "\n" + * Body := (TestPoint | BailOut | Pragma | Comment | Anything | Empty | Subtest)* + * TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? + * Directive := " # " ("todo" | "skip") (" " Reason)? + * YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + * YAMLLine := " " (YAML)* "\n" + * BailOut := "Bail out!" (" " Reason)? "\n" + * Reason := [^\n]+ + * Pragma := "pragma " [+-] PragmaKey "\n" + * PragmaKey := ([a-zA-Z0-9_-])+ + * Subtest := ("# Subtest" (": " SubtestName)?)? "\n" SubtestDocument TestPoint + * Comment := ^ (" ")* "#" [^\n]* "\n" + * Empty := [\s\t]* "\n" + * Anything := [^\n]+ "\n" + * + */ + +/** + * An LL(1) parser for TAP14/TAP13. + */ +class TapParser extends Transform { + #checker = null + #lexer = null + #currentToken = null + + #input = '' + #currentChunkAsString = '' + #lastLine = '' + + #tokens = [[]] + #flatAST = [] + #bufferedComments = [] + #bufferedTestPoints = [] + #lastTestPointDetails = {} + #yamlBlockBuffer = [] + + #currentTokenIndex = 0 + #currentTokenChunk = 0 + #subTestNestingLevel = 0 + #yamlCurrentIndentationLevel = 0 + #kSubtestBlockIndentationFactor = 4 + + #isYAMLBlock = false + #isSyncParsingEnabled = false + + constructor ({ specs = TapChecker.TAP13 } = kEmptyObject) { + super({ __proto__: null, readableObjectMode: true }) + + this.#checker = new TapChecker({ specs }) + } + + // ----------------------------------------------------------------------// + // ----------------------------- Public API -----------------------------// + // ----------------------------------------------------------------------// + + parse (chunkAsString = '', callback = null) { + this.#isSyncParsingEnabled = false + this.#currentTokenChunk = 0 + this.#currentTokenIndex = 0 + // Note: we are overwriting the input on each stream call + // This is fine because we don't want to parse previous chunks + this.#input = chunkAsString + this.#lexer = new TapLexer(chunkAsString) + + try { + this.#tokens = this.#scanTokens() + this.#parseTokens(callback) + } catch (error) { + callback(null, error) + } + } + + parseSync (input = '', callback = null) { + if (typeof input !== 'string' || input === '') { + return [] + } + + this.#isSyncParsingEnabled = true + this.#input = input + this.#lexer = new TapLexer(input) + this.#tokens = this.#scanTokens() + + this.#parseTokens(callback) + + if (this.#isYAMLBlock) { + // Looks like we have a non-ending YAML block + this.#error('Expected end of YAML block') + } + + // Manually flush the remaining buffered comments and test points + this._flush() + + return this.#flatAST + } + + // Check if the TAP content is semantically valid + // Note: Validating the TAP content requires the whole AST to be available. + check () { + if (this.#isSyncParsingEnabled) { + return this.#checker.check(this.#flatAST) + } + + // TODO(@manekinekko): when running in async mode, it doesn't make sense to + // validate the current chunk. Validation needs to whole AST to be available. + throw new ERR_TAP_VALIDATION_ERROR( + 'TAP validation is not supported for async parsing' + ) + } + // ----------------------------------------------------------------------// + // --------------------------- Transform API ----------------------------// + // ----------------------------------------------------------------------// + + processChunk (chunk) { + const str = this.#lastLine + chunk.toString('utf8') + const lines = StringPrototypeSplit(str, '\n') + this.#lastLine = ArrayPrototypeSplice(lines, lines.length - 1, 1)[0] + + let chunkAsString = lines.join('\n') + // Special case where chunk is emitted by a child process + chunkAsString = RegExpPrototypeSymbolReplace( + /\[out\] /g, + chunkAsString, + '' + ) + chunkAsString = RegExpPrototypeSymbolReplace( + /\[err\] /g, + chunkAsString, + '' + ) + chunkAsString = RegExpPrototypeSymbolReplace(/\n$/, chunkAsString, '') + chunkAsString = RegExpPrototypeSymbolReplace(/EOF$/, chunkAsString, '') + + return chunkAsString + } + + _transform (chunk, _encoding, next) { + const chunkAsString = this.processChunk(chunk) + + if (!chunkAsString) { + // Ignore empty chunks + next() + return + } + + this.parse(chunkAsString, (node, error) => { + if (error) { + next(error) + return + } + + if (node.kind === TokenKind.EOF) { + // Emit when the current chunk is fully processed and consumed + next() + } + }) + } + + // Flush the remaining buffered comments and test points + // This will be called automatically when the stream is closed + // We also call this method manually when we reach the end of the sync parsing + _flush (next = null) { + if (!this.#lastLine) { + this.#__flushPendingTestPointsAndComments() + next?.() + return + } + // Parse the remaining line + this.parse(this.#lastLine, (node, error) => { + this.#lastLine = '' + + if (error) { + next?.(error) + return + } + + if (node.kind === TokenKind.EOF) { + this.#__flushPendingTestPointsAndComments() + next?.() + } + }) + } + + #__flushPendingTestPointsAndComments () { + ArrayPrototypeForEach(this.#bufferedTestPoints, (node) => { + this.#emit(node) + }) + ArrayPrototypeForEach(this.#bufferedComments, (node) => { + this.#emit(node) + }) + + // Clean up + this.#bufferedTestPoints = [] + this.#bufferedComments = [] + } + + // ----------------------------------------------------------------------// + // ----------------------------- Private API ----------------------------// + // ----------------------------------------------------------------------// + + #scanTokens () { + return this.#lexer.scan() + } + + #parseTokens (callback = null) { + for (let index = 0; index < this.#tokens.length; index++) { + const chunk = this.#tokens[index] + this.#parseChunk(chunk) + } + + callback?.({ kind: TokenKind.EOF }) // eslint-disable-line n/no-callback-literal + } + + #parseChunk (chunk) { + this.#subTestNestingLevel = this.#getCurrentIndentationLevel(chunk) + // We compute the current index of the token in the chunk + // based on the indentation level (number of spaces). + // We also need to take into account if we are in a YAML block or not. + // If we are in a YAML block, we compute the current index of the token + // based on the indentation level of the YAML block (start block). + + if (this.#isYAMLBlock) { + this.#currentTokenIndex = + this.#yamlCurrentIndentationLevel * + this.#kSubtestBlockIndentationFactor + } else { + this.#currentTokenIndex = + this.#subTestNestingLevel * this.#kSubtestBlockIndentationFactor + this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel + } + + // Parse current chunk + const node = this.#TAPDocument(chunk) + + // Emit the parsed node to both the stream and the AST + this.#emitOrBufferCurrentNode(node) + + // Move pointers to the next chunk and reset the current token index + this.#currentTokenChunk++ + this.#currentTokenIndex = 0 + } + + #error (message) { + if (!this.#isSyncParsingEnabled) { + // When async parsing is enabled, don't throw. + // Unrecognized tokens would be ignored. + return + } + + const token = this.#currentToken || { value: '', kind: '' } + // Escape NewLine characters + if (token.value === '\n') { + token.value = '\\n' + } + + throw new ERR_TAP_PARSER_ERROR( + message, + `, received "${token.value}" (${token.kind})`, + token, + this.#input + ) + } + + #peek (shouldSkipBlankTokens = true) { + if (shouldSkipBlankTokens) { + this.#skip(TokenKind.WHITESPACE) + } + + return this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] + } + + #next (shouldSkipBlankTokens = true) { + if (shouldSkipBlankTokens) { + this.#skip(TokenKind.WHITESPACE) + } + + if (this.#tokens[this.#currentTokenChunk]) { + this.#currentToken = + this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex++] + } else { + this.#currentToken = null + } + + return this.#currentToken + } + + // Skip the provided tokens in the current chunk + #skip (...tokensToSkip) { + let token = this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] + while (token && ArrayPrototypeIncludes(tokensToSkip, token.kind)) { + // pre-increment to skip current tokens but make sure we don't advance index on the last iteration + token = this.#tokens[this.#currentTokenChunk][++this.#currentTokenIndex] + } + } + + #readNextLiterals () { + const literals = [] + let nextToken = this.#peek(false) + + // Read all literal, numeric, whitespace and escape tokens until we hit a different token + // or reach end of current chunk + while ( + nextToken && + ArrayPrototypeIncludes( + [ + TokenKind.LITERAL, + TokenKind.NUMERIC, + TokenKind.DASH, + TokenKind.PLUS, + TokenKind.WHITESPACE, + TokenKind.ESCAPE + ], + nextToken.kind + ) + ) { + const word = this.#next(false).value + + // Don't output escaped characters + if (nextToken.kind !== TokenKind.ESCAPE) { + ArrayPrototypePush(literals, word) + } + + nextToken = this.#peek(false) + } + + return ArrayPrototypeJoin(literals, '') + } + + #countLeadingSpacesInCurrentChunk (chunk) { + // Count the number of whitespace tokens in the chunk, starting from the first token + let whitespaceCount = 0 + while (chunk?.[whitespaceCount]?.kind === TokenKind.WHITESPACE) { + whitespaceCount++ + } + return whitespaceCount + } + + #addDiagnosticsToLastTestPoint (currentNode) { + const lastTestPoint = this.#bufferedTestPoints[this.#bufferedTestPoints.length - 1] + + // Diagnostic nodes are only added to Test points of the same nesting level + if (lastTestPoint && lastTestPoint.nesting === currentNode.nesting) { + lastTestPoint.node.time = this.#lastTestPointDetails.duration + + // TODO(@manekinekko): figure out where to put the other diagnostic properties + // See https://github.com/nodejs/node/pull/44952 + lastTestPoint.node.diagnostics = lastTestPoint.node.diagnostics || [] + + ArrayPrototypeForEach(currentNode.node.diagnostics, (diagnostic) => { + // Avoid adding empty diagnostics + if (diagnostic) { + ArrayPrototypePush(lastTestPoint.node.diagnostics, diagnostic) + } + }) + + this.#bufferedTestPoints = [] + } + + return lastTestPoint + } + + #flushBufferedTestPointNode (shouldClearBuffer = true) { + if (this.#bufferedTestPoints.length > 0) { + this.#emit(this.#bufferedTestPoints[0]) + + if (shouldClearBuffer) { + this.#bufferedTestPoints = [] + } + } + } + + #addCommentsToCurrentNode (currentNode) { + if (this.#bufferedComments.length > 0) { + currentNode.comments = ArrayPrototypeMap( + this.#bufferedComments, + (c) => c.node.comment + ) + this.#bufferedComments = [] + } + + return currentNode + } + + #flushBufferedComments (shouldClearBuffer = true) { + if (this.#bufferedComments.length > 0) { + ArrayPrototypeForEach(this.#bufferedComments, (node) => { + this.#emit(node) + }) + + if (shouldClearBuffer) { + this.#bufferedComments = [] + } + } + } + + #getCurrentIndentationLevel (chunk) { + const whitespaceCount = this.#countLeadingSpacesInCurrentChunk(chunk) + return (whitespaceCount / this.#kSubtestBlockIndentationFactor) | 0 + } + + #emit (node) { + if (node.kind !== TokenKind.EOF) { + ArrayPrototypePush(this.#flatAST, node) + this.push({ + __proto__: null, + ...node + }) + } + } + + #emitOrBufferCurrentNode (currentNode) { + currentNode = { + ...currentNode, + nesting: this.#subTestNestingLevel, + lexeme: this.#currentChunkAsString + } + + switch (currentNode.kind) { + // Emit these nodes + case TokenKind.UNKNOWN: + if (!currentNode.node.value) { + // Ignore unrecognized and empty nodes + break + } + + // Otherwise continue and process node + // eslint no-fallthrough + + case TokenKind.TAP_PLAN: + case TokenKind.TAP_PRAGMA: + case TokenKind.TAP_VERSION: + case TokenKind.TAP_BAIL_OUT: + case TokenKind.TAP_SUBTEST_POINT: + // Check if we have a buffered test point, and if so, emit it + this.#flushBufferedTestPointNode() + + // If we have buffered comments, add them to the current node + currentNode = this.#addCommentsToCurrentNode(currentNode) + + // Emit the current node + this.#emit(currentNode) + break + + // By default, we buffer the next test point node in case we have a diagnostic + // to add to it in the next iteration + // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) + case TokenKind.TAP_TEST_POINT: + // In case of an already buffered test point, we flush it and buffer the current one + // Because diagnostic nodes are only added to the last processed test point + this.#flushBufferedTestPointNode() + + // Buffer this node (and also add any pending comments to it) + ArrayPrototypePush( + this.#bufferedTestPoints, + this.#addCommentsToCurrentNode(currentNode) + ) + break + + // Keep buffering comments until we hit a non-comment node, then add them to the that node + // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) + case TokenKind.COMMENT: + ArrayPrototypePush(this.#bufferedComments, currentNode) + break + + // Diagnostic nodes are added to Test points of the same nesting level + case TokenKind.TAP_YAML_END: + // Emit either the last updated test point (w/ diagnostics) or the current diagnostics node alone + this.#emit( + this.#addDiagnosticsToLastTestPoint(currentNode) || currentNode + ) + break + + // In case we hit an EOF, we emit it to indicate the end of the stream + case TokenKind.EOF: + this.#emit(currentNode) + break + } + } + + #serializeChunk (chunk) { + return ArrayPrototypeJoin( + ArrayPrototypeMap( + // Exclude NewLine and EOF tokens + ArrayPrototypeFilter( + chunk, + (token) => + token.kind !== TokenKind.NEWLINE && token.kind !== TokenKind.EOF + ), + (token) => token.value + ), + '' + ) + } + + // --------------------------------------------------------------------------// + // ------------------------------ Parser rules ------------------------------// + // --------------------------------------------------------------------------// + + // TAPDocument := Version Plan Body | Version Body Plan + #TAPDocument (tokenChunks) { + this.#currentChunkAsString = this.#serializeChunk(tokenChunks) + const firstToken = this.#peek(false) + + if (firstToken) { + const { kind } = firstToken + + switch (kind) { + case TokenKind.TAP: + return this.#Version() + case TokenKind.NUMERIC: + return this.#Plan() + case TokenKind.TAP_TEST_OK: + case TokenKind.TAP_TEST_NOTOK: + return this.#TestPoint() + case TokenKind.COMMENT: + case TokenKind.HASH: + return this.#Comment() + case TokenKind.TAP_PRAGMA: + return this.#Pragma() + case TokenKind.WHITESPACE: + return this.#YAMLBlock() + case TokenKind.LITERAL: + // Check for "Bail out!" literal (case insensitive) + if ( + RegExpPrototypeExec(/^Bail\s+out!/i, this.#currentChunkAsString) + ) { + return this.#Bailout() + } else if (this.#isYAMLBlock) { + return this.#YAMLBlock() + } + + // Read token because error needs the last token details + this.#next(false) + this.#error('Expected a valid token') + + break + case TokenKind.EOF: + return firstToken + + case TokenKind.NEWLINE: + // Consume and ignore NewLine token + return this.#next(false) + default: + // Read token because error needs the last token details + this.#next(false) + this.#error('Expected a valid token') + } + } + + const node = { + kind: TokenKind.UNKNOWN, + node: { + value: this.#currentChunkAsString + } + } + + // We make sure the emitted node has the same shape + // both in sync and async parsing (for the stream interface) + return node + } + + // ----------------Version---------------- + // Version := "TAP version Number\n" + #Version () { + const tapToken = this.#peek() + + if (tapToken.kind === TokenKind.TAP) { + this.#next() // Consume the TAP token + } else { + this.#error('Expected "TAP" keyword') + } + + const versionToken = this.#peek() + if (versionToken?.kind === TokenKind.TAP_VERSION) { + this.#next() // Consume the version token + } else { + this.#error('Expected "version" keyword') + } + + const numberToken = this.#peek() + if (numberToken?.kind === TokenKind.NUMERIC) { + const version = this.#next().value + const node = { kind: TokenKind.TAP_VERSION, node: { version } } + return node + } + this.#error('Expected a version number') + } + + // ----------------Plan---------------- + // Plan := "1.." (Number) (" # " Reason)? "\n" + #Plan () { + // Even if specs mention plan starts at 1, we need to make sure we read the plan start value + // in case of a missing or invalid plan start value + const planStart = this.#next() + + if (planStart.kind !== TokenKind.NUMERIC) { + this.#error('Expected a plan start count') + } + + const planToken = this.#next() + if (planToken?.kind !== TokenKind.TAP_PLAN) { + this.#error('Expected ".." symbol') + } + + const planEnd = this.#next() + if (planEnd?.kind !== TokenKind.NUMERIC) { + this.#error('Expected a plan end count') + } + + const plan = { + start: planStart.value, + end: planEnd.value + } + + // Read optional reason + const hashToken = this.#peek() + if (hashToken) { + if (hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + plan.reason = StringPrototypeTrim(this.#readNextLiterals()) + } else if (hashToken.kind === TokenKind.LITERAL) { + this.#error('Expected "#" symbol before a reason') + } + } + + const node = { + kind: TokenKind.TAP_PLAN, + node: plan + } + + return node + } + + // ----------------TestPoint---------------- + // TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? + // Directive := " # " ("todo" | "skip") (" " Reason)? + // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + // YAMLLine := " " (YAML)* "\n" + + // Test Status: ok/not ok (required) + // Test number (recommended) + // Description (recommended, prefixed by " - ") + // Directive (only when necessary) + #TestPoint () { + const notToken = this.#peek() + let isTestFailed = false + + if (notToken.kind === TokenKind.TAP_TEST_NOTOK) { + this.#next() // skip "not" token + isTestFailed = true + } + + const okToken = this.#next() + if (okToken.kind !== TokenKind.TAP_TEST_OK) { + this.#error('Expected "ok" or "not ok" keyword') + } + + // Read optional test number + let numberToken = this.#peek() + if (numberToken && numberToken.kind === TokenKind.NUMERIC) { + numberToken = this.#next().value + } else { + numberToken = '' // Set an empty ID to indicate that the test hasn't provider an ID + } + + const test = { + // Output both failed and passed properties to make it easier for the checker to detect the test status + status: { + fail: isTestFailed, + pass: !isTestFailed, + todo: false, + skip: false + }, + id: numberToken, + description: '', + reason: '', + time: 0, + diagnostics: [] + } + + // Read optional description prefix " - " + const descriptionDashToken = this.#peek() + if (descriptionDashToken && descriptionDashToken.kind === TokenKind.DASH) { + this.#next() // skip dash + } + + // Read optional description + if (this.#peek()) { + const description = StringPrototypeTrim(this.#readNextLiterals()) + if (description) { + test.description = description + } + } + + // Read optional directive and reason + const hashToken = this.#peek() + if (hashToken && hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + } + + let todoOrSkipToken = this.#peek() + if (todoOrSkipToken && todoOrSkipToken.kind === TokenKind.LITERAL) { + if (RegExpPrototypeExec(/todo/i, todoOrSkipToken.value)) { + todoOrSkipToken = 'todo' + this.#next() // skip token + } else if (RegExpPrototypeExec(/skip/i, todoOrSkipToken.value)) { + todoOrSkipToken = 'skip' + this.#next() // skip token + } + } + + const reason = StringPrototypeTrim(this.#readNextLiterals()) + if (todoOrSkipToken) { + if (reason) { + test.reason = reason + } + + test.status.todo = todoOrSkipToken === 'todo' + test.status.skip = todoOrSkipToken === 'skip' + } + + const node = { + kind: TokenKind.TAP_TEST_POINT, + node: test + } + + return node + } + + // ----------------Bailout---------------- + // BailOut := "Bail out!" (" " Reason)? "\n" + #Bailout () { + this.#next() // skip "Bail" + this.#next() // skip "out!" + + // Read optional reason + const hashToken = this.#peek() + if (hashToken && hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + } + + const reason = StringPrototypeTrim(this.#readNextLiterals()) + + const node = { + kind: TokenKind.TAP_BAIL_OUT, + node: { bailout: true, reason } + } + + return node + } + + // ----------------Comment---------------- + // Comment := ^ (" ")* "#" [^\n]* "\n" + #Comment () { + const commentToken = this.#next() + if ( + commentToken.kind !== TokenKind.COMMENT && + commentToken.kind !== TokenKind.HASH + ) { + this.#error('Expected "#" symbol') + } + + const commentContent = this.#peek() + if (commentContent) { + if (/^Subtest:/i.test(commentContent.value)) { + this.#next() // skip subtest keyword + const name = StringPrototypeTrim(this.#readNextLiterals()) + const node = { + kind: TokenKind.TAP_SUBTEST_POINT, + node: { + name + } + } + + return node + } + + const comment = StringPrototypeTrim(this.#readNextLiterals()) + const node = { + kind: TokenKind.COMMENT, + node: { comment } + } + + return node + } + + // If there is no comment content, then we ignore the current node + } + + // ----------------YAMLBlock---------------- + // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + #YAMLBlock () { + const space1 = this.#peek(false) + if (space1 && space1.kind === TokenKind.WHITESPACE) { + this.#next(false) // skip 1st space + } + + const space2 = this.#peek(false) + if (space2 && space2.kind === TokenKind.WHITESPACE) { + this.#next(false) // skip 2nd space + } + + const yamlBlockSymbol = this.#peek(false) + + if (yamlBlockSymbol.kind === TokenKind.WHITESPACE) { + if (this.#isYAMLBlock === false) { + this.#next(false) // skip 3rd space + this.#error('Expected valid YAML indentation (2 spaces)') + } + } + + if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_START) { + if (this.#isYAMLBlock) { + // Looks like we have another YAML start block, but we didn't close the previous one + this.#error('Unexpected YAML start marker') + } + + this.#isYAMLBlock = true + this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel + this.#lastTestPointDetails = {} + + // Consume the YAML start marker + this.#next(false) // skip "---" + + // No need to pass this token to the stream interface + return + } else if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_END) { + this.#next(false) // skip "..." + + if (!this.#isYAMLBlock) { + // Looks like we have an YAML end block, but we didn't encounter any YAML start marker + this.#error('Unexpected YAML end marker') + } + + this.#isYAMLBlock = false + + const diagnostics = this.#yamlBlockBuffer + this.#yamlBlockBuffer = [] // Free the buffer for the next YAML block + + const node = { + kind: TokenKind.TAP_YAML_END, + node: { + diagnostics + } + } + + return node + } + + if (this.#isYAMLBlock) { + this.#YAMLLine() + } else { + return { + kind: TokenKind.UNKNOWN, + node: { + value: yamlBlockSymbol.value + } + } + } + } + + // ----------------YAMLLine---------------- + // YAMLLine := " " (YAML)* "\n" + #YAMLLine () { + const yamlLiteral = this.#readNextLiterals() + const { 0: key, 1: value } = StringPrototypeSplit(yamlLiteral, ':') + + // Note that this.#lastTestPointDetails has been cleared when we encounter a YAML start marker + + switch (key) { + case 'duration_ms': + this.#lastTestPointDetails.duration = Number(value) + break + // Below are diagnostic properties introduced in https://github.com/nodejs/node/pull/44952 + case 'expected': + this.#lastTestPointDetails.expected = Boolean(value) + break + case 'actual': + this.#lastTestPointDetails.actual = Boolean(value) + break + case 'operator': + this.#lastTestPointDetails.operator = String(value) + break + } + + ArrayPrototypePush(this.#yamlBlockBuffer, yamlLiteral) + } + + // ----------------PRAGMA---------------- + // Pragma := "pragma " [+-] PragmaKey "\n" + // PragmaKey := ([a-zA-Z0-9_-])+ + // TODO(@manekinekko): pragmas are parsed but not used yet! TapChecker() should take care of that. + #Pragma () { + const pragmaToken = this.#next() + if (pragmaToken.kind !== TokenKind.TAP_PRAGMA) { + this.#error('Expected "pragma" keyword') + } + + const pragmas = {} + + let nextToken = this.#peek() + while ( + nextToken && + ArrayPrototypeIncludes( + [TokenKind.NEWLINE, TokenKind.EOF, TokenKind.EOL], + nextToken.kind + ) === false + ) { + let isEnabled = true + const pragmaKeySign = this.#next() + if (pragmaKeySign.kind === TokenKind.PLUS) { + isEnabled = true + } else if (pragmaKeySign.kind === TokenKind.DASH) { + isEnabled = false + } else { + this.#error('Expected "+" or "-" before pragma keys') + } + + const pragmaKeyToken = this.#peek() + if (pragmaKeyToken.kind !== TokenKind.LITERAL) { + this.#error('Expected pragma key') + } + + let pragmaKey = this.#next().value + + // In some cases, pragma key can be followed by a comma separator, + // so we need to remove it + pragmaKey = RegExpPrototypeSymbolReplace(/,/g, pragmaKey, '') + + pragmas[pragmaKey] = isEnabled + nextToken = this.#peek() + } + + const node = { + kind: TokenKind.TAP_PRAGMA, + node: { + pragmas + } + } + + return node + } +} + +module.exports = { TapParser } diff --git a/lib/internal/test_runner/tap_stream.js b/lib/internal/test_runner/tap_stream.js index 8b53c9b..42ebfb9 100644 --- a/lib/internal/test_runner/tap_stream.js +++ b/lib/internal/test_runner/tap_stream.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/e260b373f13c150eb5bdf4c336d4b6b764b59c8e/lib/internal/test_runner/tap_stream.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/tap_stream.js 'use strict' @@ -10,6 +10,7 @@ const { ArrayPrototypeShift, ObjectEntries, StringPrototypeReplaceAll, + StringPrototypeToUpperCase, StringPrototypeSplit, RegExpPrototypeSymbolReplace } = require('#internal/per_context/primordials') @@ -18,11 +19,11 @@ const Readable = require('#internal/streams/readable') const { isError, kEmptyObject } = require('#internal/util') const kFrameStartRegExp = /^ {4}at / const kLineBreakRegExp = /\n|\r\n/ +const kDefaultTAPVersion = 13 const inspectOptions = { colors: false, breakLength: Infinity } let testModule // Lazy loaded due to circular dependency. function lazyLoadTest () { - // Node.js 14.x does not support Logical_nullish_assignment operator testModule = testModule ?? require('#internal/test_runner/test') return testModule @@ -54,16 +55,16 @@ class TapStream extends Readable { this.#tryPush(`Bail out!${message ? ` ${tapEscape(message)}` : ''}\n`) } - fail (indent, testNumber, name, duration, error, directive) { - this.emit('test:fail', { __proto__: null, name, testNumber, duration, ...directive, error }) + fail (indent, testNumber, name, details, directive) { + this.emit('test:fail', { __proto__: null, name, testNumber, details, ...directive }) this.#test(indent, testNumber, 'not ok', name, directive) - this.#details(indent, duration, error) + this.#details(indent, details) } - ok (indent, testNumber, name, duration, directive) { - this.emit('test:pass', { __proto__: null, name, testNumber, duration, ...directive }) + ok (indent, testNumber, name, details, directive) { + this.emit('test:pass', { __proto__: null, name, testNumber, details, ...directive }) this.#test(indent, testNumber, 'ok', name, directive) - this.#details(indent, duration, null) + this.#details(indent, details) } plan (indent, count, explanation) { @@ -84,9 +85,11 @@ class TapStream extends Readable { this.#tryPush(`${indent}# Subtest: ${tapEscape(name)}\n`) } - #details (indent, duration, error) { + #details (indent, data = kEmptyObject) { + const { error, duration, yaml } = data let details = `${indent} ---\n` + details += `${yaml || ''}` details += jsToYaml(indent, 'duration_ms', duration) details += jsToYaml(indent, null, error) details += `${indent} ...\n` @@ -98,8 +101,8 @@ class TapStream extends Readable { this.#tryPush(`${indent}# ${tapEscape(message)}\n`) } - version () { - this.#tryPush('TAP version 13\n') + version (spec = kDefaultTAPVersion) { + this.#tryPush(`TAP version ${spec}\n`) } #test (indent, testNumber, status, name, directive = kEmptyObject) { @@ -110,10 +113,11 @@ class TapStream extends Readable { } line += ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(directive), ({ 0: key, 1: value }) => ( - ` # ${key.toUpperCase()}${value ? ` ${tapEscape(value)}` : ''}` + ` # ${StringPrototypeToUpperCase(key)}${value ? ` ${tapEscape(value)}` : ''}` )), '') line += '\n' + this.#tryPush(line) } @@ -233,7 +237,9 @@ function jsToYaml (indent, name, value) { StringPrototypeSplit(errStack, kLineBreakRegExp), (frame) => { const processed = RegExpPrototypeSymbolReplace( - kFrameStartRegExp, frame, '' + kFrameStartRegExp, + frame, + '' ) if (processed.length > 0 && processed.length !== frame.length) { @@ -246,7 +252,7 @@ function jsToYaml (indent, name, value) { const frameDelimiter = `\n${indent} ` result += `${indent} stack: |-${frameDelimiter}` - result += `${ArrayPrototypeJoin(frames, `${frameDelimiter}`)}\n` + result += `${ArrayPrototypeJoin(frames, frameDelimiter)}\n` } } } diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 91aeaea..5a5c136 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/7c6682957b3c5f86d0616cebc0ad09cc2a1fd50d/lib/internal/test_runner/test.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/test.js 'use strict' @@ -176,7 +176,6 @@ class Test extends AsyncResource { if (parent === null) { this.concurrency = 1 this.indent = '' - this.indentString = kDefaultIndent this.only = testOnlyFlag this.reporter = new TapStream() this.runOnlySubtests = this.only @@ -185,11 +184,10 @@ class Test extends AsyncResource { } else { const indent = parent.parent === null ? parent.indent - : parent.indent + parent.indentString + : parent.indent + kDefaultIndent this.concurrency = parent.concurrency this.indent = indent - this.indentString = parent.indentString this.only = only ?? !parent.runOnlySubtests this.reporter = parent.reporter this.runOnlySubtests = !this.only @@ -666,6 +664,7 @@ class Test extends AsyncResource { this.reportSubtest() } let directive + const details = { __proto__: null, duration: this.#duration() } if (this.skipped) { directive = this.reporter.getSkip(this.message) @@ -674,9 +673,10 @@ class Test extends AsyncResource { } if (this.passed) { - this.reporter.ok(this.indent, this.testNumber, this.name, this.#duration(), directive) + this.reporter.ok(this.indent, this.testNumber, this.name, details, directive) } else { - this.reporter.fail(this.indent, this.testNumber, this.name, this.#duration(), this.error, directive) + details.error = this.error + this.reporter.fail(this.indent, this.testNumber, this.name, details, directive) } for (let i = 0; i < this.diagnostics.length; i++) { diff --git a/test/fixtures/test-runner/invalid-tap.js b/test/fixtures/test-runner/invalid-tap.js new file mode 100644 index 0000000..1073bd8 --- /dev/null +++ b/test/fixtures/test-runner/invalid-tap.js @@ -0,0 +1,3 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/fixtures/test-runner/invalid-tap.js + +console.log('invalid tap output') diff --git a/test/fixtures/test-runner/nested.js b/test/fixtures/test-runner/nested.js new file mode 100644 index 0000000..3d01534 --- /dev/null +++ b/test/fixtures/test-runner/nested.js @@ -0,0 +1,23 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/fixtures/test-runner/nested.js +'use strict' +const test = require('#node:test') + +test('level 0a', { concurrency: 4 }, async (t) => { + t.test('level 1a', async (t) => { + }) + + t.test('level 1b', async (t) => { + throw new Error('level 1b error') + }) + + t.test('level 1c', { skip: 'aaa' }, async (t) => { + }) + + t.test('level 1d', async (t) => { + t.diagnostic('level 1d diagnostic') + }) +}) + +test('level 0b', async (t) => { + throw new Error('level 0b error') +}) diff --git a/test/parallel/test-runner-cli.js b/test/parallel/test-runner-cli.js index 63d06e3..397ea82 100644 --- a/test/parallel/test-runner-cli.js +++ b/test/parallel/test-runner-cli.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/a165193c5c8e4bcfbd12b2c3f6e55a81a251c258/test/parallel/test-runner-cli.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-cli.js 'use strict' require('../common') const assert = require('assert') @@ -116,3 +116,55 @@ const testFixtures = fixtures.path('test-runner') // assert.match(stderr, /--test/) // }) // } + +{ + // Test combined stream outputs + const args = [ + '--test', + 'test/fixtures/test-runner/index.test.js', + 'test/fixtures/test-runner/nested.js', + 'test/fixtures/test-runner/invalid-tap.js' + ] + const child = spawnSync(process.execPath, args) + + assert.strictEqual(child.status, 1) + assert.strictEqual(child.signal, null) + assert.strictEqual(child.stderr.toString(), '') + const stdout = child.stdout.toString() + assert.match(stdout, /# Subtest: .+index\.test\.js/) + assert.match(stdout, / {4}# Subtest: this should pass/) + assert.match(stdout, / {4}ok 1 - this should pass/) + assert.match(stdout, / {6}---/) + assert.match(stdout, / {6}duration_ms: .*/) + assert.match(stdout, / {6}\.\.\./) + assert.match(stdout, / {4}1\.\.1/) + + assert.match(stdout, /ok 1 - .+index\.test\.js/) + + assert.match(stdout, /# Subtest: .+invalid-tap\.js/) + assert.match(stdout, / {4}# invalid tap output/) + assert.match(stdout, /ok 2 - .+invalid-tap\.js/) + + assert.match(stdout, /# Subtest: .+nested\.js/) + assert.match(stdout, / {4}# Subtest: level 0a/) + assert.match(stdout, / {8}# Subtest: level 1a/) + assert.match(stdout, / {8}ok 1 - level 1a/) + assert.match(stdout, / {8}# Subtest: level 1b/) + assert.match(stdout, / {8}not ok 2 - level 1b/) + assert.match(stdout, / {10}code: 'ERR_TEST_FAILURE'/) + assert.match(stdout, / {10}stack: |-'/) + assert.match(stdout, / {12}TestContext\. .*/) + assert.match(stdout, / {8}# Subtest: level 1c/) + assert.match(stdout, / {8}ok 3 - level 1c # SKIP aaa/) + assert.match(stdout, / {8}# Subtest: level 1d/) + assert.match(stdout, / {8}ok 4 - level 1d/) + assert.match(stdout, / {4}not ok 1 - level 0a/) + assert.match(stdout, / {6}error: '1 subtest failed'/) + assert.match(stdout, / {4}# Subtest: level 0b/) + assert.match(stdout, / {4}not ok 2 - level 0b/) + assert.match(stdout, / {6}error: 'level 0b error'/) + assert.match(stdout, /not ok 3 - .+nested\.js/) + assert.match(stdout, /# tests 3/) + assert.match(stdout, /# pass 2/) + assert.match(stdout, /# fail 1/) +} diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 66837ae..b41dd33 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/59527de13d39327eb3dfa8dedc92241eb40066d5/test/parallel/test-runner-run.mjs +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-run.mjs import common from '../common/index.js' import fixtures from '../common/fixtures.js' @@ -31,7 +31,7 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { it('should succeed with a file', async () => { const stream = run({ files: [join(testFixtures, 'test/random.cjs')] }) stream.on('test:fail', common.mustNotCall()) - stream.on('test:pass', common.mustCall(1)) + stream.on('test:pass', common.mustCall(2)) // eslint-disable-next-line no-unused-vars for await (const _ of stream); // TODO(MoLow): assert.snapshot }) @@ -39,7 +39,7 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { it('should run same file twice', async () => { const stream = run({ files: [join(testFixtures, 'test/random.cjs'), join(testFixtures, 'test/random.cjs')] }) stream.on('test:fail', common.mustNotCall()) - stream.on('test:pass', common.mustCall(2)) + stream.on('test:pass', common.mustCall(4)) // eslint-disable-next-line no-unused-vars for await (const _ of stream); // TODO(MoLow): assert.snapshot }) diff --git a/test/parallel/test-runner-tap-checker.js b/test/parallel/test-runner-tap-checker.js new file mode 100644 index 0000000..8fbc8d3 --- /dev/null +++ b/test/parallel/test-runner-tap-checker.js @@ -0,0 +1,120 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-checker.js +'use strict' +// Flags: --expose-internals + +require('../common') +const assert = require('assert') + +const { TapParser } = require('#internal/test_runner/tap_parser') +const { TapChecker } = require('#internal/test_runner/tap_checker') + +function TAPChecker (input) { + // parse + const parser = new TapParser({ specs: TapChecker.TAP14 }) + parser.parseSync(input) + parser.check() +} + +[ + ['TAP version 14', 'missing TAP plan'], + [` +TAP version 14 +1..1 + `, 'missing Test Points'], + [` +TAP version 14 +1..1 +ok 2 + `, 'test 2 is out of plan range 1..1'], + [` +TAP version 14 +3..1 +ok 2 + `, 'plan start 3 is greater than plan end 1'], + [` +TAP version 14 +2..3 +ok 1 +ok 2 +ok 3 + `, 'test 1 is out of plan range 2..3'] + +].forEach(([str, message]) => { + assert.throws(() => TAPChecker(str), { + code: 'ERR_TAP_VALIDATION_ERROR', + message + }) +}) + +// Valid TAP14 should not throw +TAPChecker(` +TAP version 14 +1..1 +ok +`) + +// Valid comment line shout not throw. +TAPChecker(` +TAP version 14 +1..5 +ok 1 - approved operating system +# $^0 is solaris +ok 2 - # SKIP no /sys directory +ok 3 - # SKIP no /sys directory +ok 4 - # SKIP no /sys directory +ok 5 - # SKIP no /sys directory +`) + +// Valid empty test plan should not throw. +TAPChecker(` +TAP version 14 +1..0 # skip because English-to-French translator isn't installed +`) + +// Valid test plan count should not throw. +TAPChecker(` +TAP version 14 +1..4 +ok 1 - Creating test program +ok 2 - Test program runs, no error +not ok 3 - infinite loop # TODO halting problem unsolved +not ok 4 - infinite loop 2 # TODO halting problem unsolved +`) + +// Valid YAML diagnostic should not throw. +TAPChecker(` +TAP version 14 +ok - created Board +ok +ok +ok +ok +ok +ok +ok + --- + message: "Board layout" + severity: comment + dump: + board: + - ' 16G 05C ' + - ' G N C C C G ' + - ' G C + ' + - '10C 01G 03C ' + - 'R N G G A G C C C ' + - ' R G C + ' + - ' 01G 17C 00C ' + - ' G A G G N R R N R ' + - ' G R G ' + ... +ok - board has 7 tiles + starter tile +1..9 +`) + +// Valid Bail out should not throw. +TAPChecker(` +TAP version 14 +1..573 +not ok 1 - database handle +Bail out! Couldn't connect to database. +`) diff --git a/test/parallel/test-runner-tap-lexer.js b/test/parallel/test-runner-tap-lexer.js new file mode 100644 index 0000000..801a8e6 --- /dev/null +++ b/test/parallel/test-runner-tap-lexer.js @@ -0,0 +1,447 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-lexer.js +'use strict' +// Flags: --expose-internals + +require('../common') +const assert = require('assert') + +const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer') + +function TAPLexer (input) { + const lexer = new TapLexer(input) + return lexer.scan().flat() +} + +{ + const tokens = TAPLexer('') + + assert.strictEqual(tokens[0].kind, TokenKind.EOF) + assert.strictEqual(tokens[0].value, '') +} + +{ + const tokens = TAPLexer('TAP version 14'); + + [ + { kind: TokenKind.TAP, value: 'TAP' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_VERSION, value: 'version' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '14' }, + { kind: TokenKind.EOL, value: '' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('1..5 # reason'); + + [ + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.TAP_PLAN, value: '..' }, + { kind: TokenKind.NUMERIC, value: '5' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'reason' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer( + '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + ); + + [ + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.TAP_PLAN, value: '..' }, + { kind: TokenKind.NUMERIC, value: '5' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'reason' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '"' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '!"' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.LITERAL, value: "$%&'()*" }, + { kind: TokenKind.PLUS, value: '+' }, + { kind: TokenKind.LITERAL, value: ',' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.LITERAL, value: './:;<=>?@[]^_`{|}~' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('not ok'); + + [ + { kind: TokenKind.TAP_TEST_NOTOK, value: 'not' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` +ok 1 +not ok 2 +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_NOTOK, value: 'not' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '2' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` +ok 1 + ok 1 +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 description'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description # todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description \\# todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description \\ # todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer( + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + ); + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'world' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'TODO' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'escape' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'characters' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'with' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description # ##'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('# comment'); + [ + { kind: TokenKind.COMMENT, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'comment' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('#'); + + [ + { kind: TokenKind.COMMENT, value: '#' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` + --- + message: "description" + severity: fail + ... +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_YAML_START, value: '---' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'message:' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '"description"' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'severity:' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'fail' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_YAML_END, value: '...' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('pragma +strict -warnings'); + + [ + { kind: TokenKind.TAP_PRAGMA, value: 'pragma' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.PLUS, value: '+' }, + { kind: TokenKind.LITERAL, value: 'strict' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.LITERAL, value: 'warnings' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('Bail out! Error'); + + [ + { kind: TokenKind.LITERAL, value: 'Bail' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'out!' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'Error' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} diff --git a/test/parallel/test-runner-tap-parser-stream.js b/test/parallel/test-runner-tap-parser-stream.js new file mode 100644 index 0000000..63a3ff3 --- /dev/null +++ b/test/parallel/test-runner-tap-parser-stream.js @@ -0,0 +1,631 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-parser-stream.js +// Flags: --expose-internals +'use strict' +const common = require('../common') +const assert = require('node:assert') +const { TapParser } = require('#internal/test_runner/tap_parser') +const { TapChecker } = require('#internal/test_runner/tap_checker') +const { toArray } = require('#internal/streams/operators').promiseReturningOperators + +const cases = [ + { + input: 'TAP version 13', + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + } + ] + }, + { + input: 'invalid tap', + expected: [ + { + nesting: 0, + kind: 'Unknown', + node: { value: 'invalid tap' }, + lexeme: 'invalid tap' + } + ] + }, + { + input: 'TAP version 13\ninvalid tap after harness', + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'Unknown', + node: { value: 'invalid tap after harness' }, + lexeme: 'invalid tap after harness' + } + ] + }, + { + input: `TAP version 13 + # nested diagnostic +# diagnostic comment`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 1, + kind: 'Comment', + node: { comment: 'nested diagnostic' }, + lexeme: ' # nested diagnostic' + }, + { + nesting: 0, + kind: 'Comment', + node: { comment: 'diagnostic comment' }, + lexeme: '# diagnostic comment' + } + ] + }, + { + input: `TAP version 13 + 1..5 +1..3 +2..2`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 1, + kind: 'PlanKeyword', + node: { start: '1', end: '5' }, + lexeme: ' 1..5' + }, + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '1', end: '3' }, + lexeme: '1..3' + }, + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '2', end: '2' }, + lexeme: '2..2' + } + ] + }, + { + input: `TAP version 13 +ok 1 - test +ok 2 - test # SKIP +not ok 3 - test # TODO reason`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: true }, + id: '2', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 - test # SKIP' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: true, skip: false }, + id: '3', + description: 'test', + reason: 'reason', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok 3 - test # TODO reason' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test +ok 1 - test +ok 2 - test`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test' }, + lexeme: '# Subtest: test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 - test' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test +ok 1 - test + --- + foo: bar + duration_ms: 0.0001 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test' }, + lexeme: '# Subtest: test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0.0001, + diagnostics: [ + 'foo: bar', + 'duration_ms: 0.0001', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test/fixtures/test-runner/index.test.js + # Subtest: this should pass + ok 1 - this should pass + --- + duration_ms: 0.0001 + ... + 1..1`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + kind: 'SubTestPointKeyword', + lexeme: '# Subtest: test/fixtures/test-runner/index.test.js', + nesting: 0, + node: { + name: 'test/fixtures/test-runner/index.test.js' + } + }, + { + kind: 'SubTestPointKeyword', + lexeme: ' # Subtest: this should pass', + nesting: 1, + node: { + name: 'this should pass' + } + }, + { + kind: 'TestPointKeyword', + lexeme: ' ok 1 - this should pass', + nesting: 1, + node: { + description: 'this should pass', + diagnostics: ['duration_ms: 0.0001'], + id: '1', + reason: '', + status: { + fail: false, + pass: true, + skip: false, + todo: false + }, + time: 0.0001 + } + }, + { + kind: 'PlanKeyword', + lexeme: ' 1..1', + nesting: 1, + node: { + end: '1', + start: '1' + } + } + ] + }, + { + input: `TAP version 13 +# Subtest: test 1 +ok 1 - test 1 + --- + foo: bar + duration_ms: 1.00 + prop: |- + multiple + lines + ... +# Subtest: test 2 +ok 2 - test 2 + --- + duration_ms: 2.00 + ... +# Subtest: test 3 +ok 3 - test 3 + --- + foo: bar + duration_ms: 3.00 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 1' }, + lexeme: '# Subtest: test 1' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test 1', + reason: '', + time: 1.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 1.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test 1' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 2' }, + lexeme: '# Subtest: test 2' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test 2', + reason: '', + time: 2.0, + diagnostics: ['duration_ms: 2.00'] + }, + lexeme: 'ok 2 - test 2' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 3' }, + lexeme: '# Subtest: test 3' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'test 3', + reason: '', + time: 3.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 3.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 3 - test 3' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test 1 +ok 1 - test 1 + --- + foo: bar + duration_ms: 1.00 + prop: |- + multiple + lines + ... + # Subtest: test 11 + ok 11 - test 11 + --- + duration_ms: 11.00 + ... + # Subtest: test 111 + ok 111 - test 111 + --- + foo: bar + duration_ms: 111.00 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 1' }, + lexeme: '# Subtest: test 1' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test 1', + reason: '', + time: 1.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 1.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test 1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'test 11' }, + lexeme: ' # Subtest: test 11' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '11', + description: 'test 11', + reason: '', + time: 11.0, + diagnostics: ['duration_ms: 11.00'] + }, + lexeme: ' ok 11 - test 11' + }, + { + nesting: 2, + kind: 'SubTestPointKeyword', + node: { name: 'test 111' }, + lexeme: ' # Subtest: test 111' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '111', + description: 'test 111', + reason: '', + time: 111.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 111.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: ' ok 111 - test 111' + } + ] + } +]; + +(async () => { + for (const { input, expected } of cases) { + const parser = new TapParser() + parser.write(input) + parser.end() + const actual = await toArray.call(parser) + assert.deepStrictEqual( + actual, + expected.map((item) => ({ __proto__: null, ...item })) + ) + } +})().then(common.mustCall()); + +(async () => { + const expected = [ + { + kind: 'PlanKeyword', + node: { start: '1', end: '3' }, + nesting: 0, + lexeme: '1..3' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'Input file opened', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - Input file opened' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '2', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + nesting: 0, + lexeme: 'not ok 2 ' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'foobar' }, + nesting: 1, + lexeme: ' # Subtest: foobar' + }, + { + __proto__: null, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '3', + description: '', + reason: '', + time: 0.0001, + diagnostics: [ + 'foo: bar', + 'duration_ms: 0.0001', + 'prop: |-', + ' foo', + ' bar' + ] + }, + nesting: 0, + lexeme: 'ok 3 # TODO' + } + ] + + const parser = new TapParser({ specs: TapChecker.TAP14 }) + parser.write('\n') + parser.write('1') + parser.write('.') + parser.write('.') + parser.write('3') + parser.write('\n') + parser.write('ok 1 ') + parser.write('- Input file opened\n') + parser.write('not') + parser.write(' ok') + parser.write(' 2 \n') + parser.write('\n') + parser.write(' # ') + parser.write('Subtest: foo') + parser.write('bar') + parser.write('\n') + parser.write('') + parser.write('ok') + parser.write(' 3 #') + parser.write(' TODO') + parser.write('\n') + parser.write(' ---\n') + parser.write(' foo: bar\n') + parser.write(' duration_ms: ') + parser.write(' 0.0001\n') + parser.write(' prop: |-\n') + parser.write(' foo\n') + parser.write(' bar\n') + parser.write(' ...\n') + parser.end() + const actual = await toArray.call(parser) + assert.deepStrictEqual( + actual, + expected.map((item) => ({ __proto__: null, ...item })) + ) +})().then(common.mustCall()) diff --git a/test/parallel/test-runner-tap-parser.js b/test/parallel/test-runner-tap-parser.js new file mode 100644 index 0000000..aa6fbb4 --- /dev/null +++ b/test/parallel/test-runner-tap-parser.js @@ -0,0 +1,1315 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-parser.js +'use strict' +// Flags: --expose-internals + +/* eslint no-lone-blocks: 0 */ + +require('../common') +const assert = require('assert') + +const { TapParser } = require('#internal/test_runner/tap_parser') + +function TAPParser (input) { + const parser = new TapParser() + const ast = parser.parseSync(input) + return ast +} + +// Comment + +{ + const ast = TAPParser('# comment') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'Comment', + node: { comment: 'comment' }, + lexeme: '# comment' + } + ]) +} + +{ + const ast = TAPParser('#') + assert.deepStrictEqual(ast, [ + { + kind: 'Comment', + nesting: 0, + node: { + comment: '' + }, + lexeme: '#' + } + ]) +} + +{ + const ast = TAPParser('####') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'Comment', + node: { comment: '###' }, + lexeme: '####' + } + ]) +} + +// Empty input + +{ + const ast = TAPParser('') + assert.deepStrictEqual(ast, []) +} + +// TAP version + +{ + const ast = TAPParser('TAP version 14') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '14' }, + lexeme: 'TAP version 14' + } + ]) +} + +{ + assert.throws(() => TAPParser('TAP version'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a version number, received "version" (VersionKeyword) at line 1, column 5 (start 4, end 10)' + }) +} + +{ + assert.throws(() => TAPParser('TAP'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected "version" keyword, received "TAP" (TAPKeyword) at line 1, column 1 (start 0, end 2)' + }) +} + +// Test plan + +{ + const ast = TAPParser('1..5 # reason') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '1', end: '5', reason: 'reason' }, + lexeme: '1..5 # reason' + } + ]) +} + +{ + const ast = TAPParser( + '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PlanKeyword', + node: { + start: '1', + end: '5', + reason: 'reason " !"\\#$%&\'()*+,-./:;<=>?@[]^_`{|}~' + }, + lexeme: '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + } + ]) +} + +{ + assert.throws(() => TAPParser('1..'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a plan end count, received "" (EOL) at line 1, column 4 (start 3, end 3)' + }) +} + +{ + assert.throws(() => TAPParser('1..abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected ".." symbol, received "..abc" (Literal) at line 1, column 2 (start 1, end 5)' + }) +} + +{ + assert.throws(() => TAPParser('1..-1'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a plan end count, received "-" (Dash) at line 1, column 4 (start 3, end 3)' + }) +} + +{ + assert.throws(() => TAPParser('1.1'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected ".." symbol, received "." (Literal) at line 1, column 2 (start 1, end 1)' + }) +} + +// Test point + +{ + const ast = TAPParser('ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok' + } + ]) +} + +{ + const ast = TAPParser('not ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok' + } + ]) +} + +{ + const ast = TAPParser('ok 1') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1' + } + ]) +} + +{ + const ast = TAPParser(` +ok 111 +not ok 222 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '111', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 111' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '222', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok 222' + } + ]) +} + +{ + // Nested tests + const ast = TAPParser(` +ok 1 - parent + ok 2 - child +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'parent', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - parent' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'child', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - child' + } + ]) +} + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 + + # Subtest: nested2 + ok 2 - nested2 + + # Subtest: nested3 + ok 3 - nested3 + + # Subtest: nested4 + ok 4 - nested4 + +ok 1 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2' }, + lexeme: ' # Subtest: nested2' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - nested2' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested3' }, + lexeme: ' # Subtest: nested3' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested3', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested3' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested4' }, + lexeme: ' # Subtest: nested4' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'nested4', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 4 - nested4' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - nested1' + } + ]) +} + +// Nested tests as comment + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 - test nested1 + + # Subtest: nested2 + ok 2 - test nested2 + + ok 3 - nested2 + +ok 4 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1 - test nested1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2' }, + lexeme: ' # Subtest: nested2' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - test nested2' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested2' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 4 - nested1' + } + ]) +} + +// Multiple nested tests as comment + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 - test nested1 + + # Subtest: nested2a + ok 2 - test nested2a + + ok 3 - nested2a + + # Subtest: nested2b + ok 4 - test nested2b + + ok 5 - nested2b + +ok 6 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1 - test nested1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2a' }, + lexeme: ' # Subtest: nested2a' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test nested2a', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - test nested2a' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested2a', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested2a' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2b' }, + lexeme: ' # Subtest: nested2b' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'test nested2b', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 4 - test nested2b' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '5', + description: 'nested2b', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 5 - nested2b' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '6', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 6 - nested1' + } + ]) +} + +{ + const ast = TAPParser('ok 1 description') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 description' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description # todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description # todo' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description \\# todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description # todo', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description \\# todo' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description \\ # todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description \\ # todo' + } + ]) +} + +{ + const ast = TAPParser( + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description # \\ world', + reason: 'escape # characters with \\', + time: 0, + diagnostics: [] + }, + lexeme: + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description # ##') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '##', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description # ##' + } + ]) +} + +{ + const ast = TAPParser( + 'ok 2 not skipped: https://example.com/page.html#skip is a url' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'not skipped: https://example.com/page.html#skip is a url', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 not skipped: https://example.com/page.html#skip is a url' + } + ]) +} + +{ + const ast = TAPParser('ok 3 - #SkIp case insensitive, so this is skipped') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: true }, + id: '3', + description: '', + reason: 'case insensitive, so this is skipped', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 3 - #SkIp case insensitive, so this is skipped' + } + ]) +} + +{ + const ast = TAPParser('ok ok ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: 'ok ok', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok ok ok' + } + ]) +} + +{ + const ast = TAPParser('ok not ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: 'not ok', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok not ok' + } + ]) +} + +{ + const ast = TAPParser('ok 1..1') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1..1' + } + ]) +} + +// Diagnostic + +{ + // Note the leading 2 valid spaces + const ast = TAPParser(` + --- + message: 'description' + property: 'value' + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { + diagnostics: ["message: 'description'", "property: 'value'"] + }, + lexeme: ' ...' + } + ]) +} + +{ + // Note the leading 2 valid spaces + const ast = TAPParser(` + --- + message: "Board layout" + severity: comment + dump: + board: + - ' 16G 05C ' + - ' G N C C C G ' + - ' G C + ' + - '10C 01G 03C ' + - 'R N G G A G C C C ' + - ' R G C + ' + - ' 01G 17C 00C ' + - ' G A G G N R R N R ' + - ' G R G ' + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { + diagnostics: [ + 'message: "Board layout"', + 'severity: comment', + 'dump:', + ' board:', + " - ' 16G 05C '", + " - ' G N C C C G '", + " - ' G C + '", + " - '10C 01G 03C '", + " - 'R N G G A G C C C '", + " - ' R G C + '", + " - ' 01G 17C 00C '", + " - ' G A G G N R R N R '", + " - ' G R G '" + ] + }, + lexeme: ' ...' + } + ]) +} + +{ + const ast = TAPParser(` + --- + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { diagnostics: [] }, + lexeme: ' ...' + } + ]) +} + +{ + assert.throws( + () => + TAPParser( + ` + message: 'description' + property: 'value' + ...` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Unexpected YAML end marker, received "..." (YamlEndKeyword) at line 4, column 3 (start 48, end 50)' + } + ) +} + +{ + assert.throws( + () => + TAPParser( + ` + --- + message: 'description' + property: 'value'` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received "\'value\'" (Literal) at line 4, column 13 (start 44, end 50)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 3 spaces before --- + TAPParser( + ` + --- + message: 'description' + property: 'value' + ...` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected valid YAML indentation (2 spaces), received " " (Whitespace) at line 2, column 3 (start 3, end 3)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 5 spaces before --- + // This is a special case because the YAML block is indented by 1 space + // the extra 4 spaces are those of the subtest nesting level. + // However, the remaining content of the YAML block is indented by 2 spaces + // making it belong to the parent level. + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received "\'value\'" (Literal) at line 4, column 13 (start 47, end 53)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 4 spaces before --- + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "---" (YamlStartKeyword) at line 2, column 5 (start 5, end 7)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 4 spaces before ... + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received " " (Whitespace) at line 6, column 2 (start 61, end 61)' + } + ) +} + +// Pragma + +{ + const ast = TAPParser('pragma +strict, -warnings') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PragmaKeyword', + node: { + pragmas: { strict: true, warnings: false } + }, + lexeme: 'pragma +strict, -warnings' + } + ]) +} + +// Bail out + +{ + const ast = TAPParser('Bail out! Error') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'BailOutKeyword', + node: { bailout: true, reason: 'Error' }, + lexeme: 'Bail out! Error' + } + ]) +} + +// Non-recognized + +{ + assert.throws(() => TAPParser('abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "abc" (Literal) at line 1, column 1 (start 0, end 2)' + }) +} + +{ + assert.throws(() => TAPParser(' abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "abc" (Literal) at line 1, column 5 (start 4, end 6)' + }) +} + +// TAP document (with diagnostics) + +{ + const ast = TAPParser(` +# Comment on version 13 +# Another comment on version 13 + +TAP version 13 + +# Subtest: /test.js + # Subtest: level 0a + # Subtest: level 1a + # Comment test point 1a + # Comment test point 1aa + ok 1 - level 1a + --- + duration_ms: 1.676996 + ... + # Comment plan 1a + # Comment plan 1aa + 1..1 + # Comment closing test point 1a + # Comment closing test point 1aa + not ok 1 - level 1a + --- + duration_ms: 0.122839 + failureType: 'testCodeFailure' + error: 'level 0b error' + code: 'ERR_TEST_FAILURE' + stack: |- + TestContext. (/test.js:23:9) + ... + 1..1 + not ok 1 - level 0a + --- + duration_ms: 84.920487 + failureType: 'subtestsFailed' + exitCode: 1 + error: '3 subtests failed' + code: 'ERR_TEST_FAILURE' + ... + # Comment plan 0a + # Comment plan 0aa + 1..1 + +# Comment closing test point 0a + +# Comment closing test point 0aa + +not ok 1 - /test.js +# tests 1 +# pass 0 +# fail 1 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 87.077507 +`) + + assert.deepStrictEqual(ast, [ + { + kind: 'VersionKeyword', + node: { version: '13' }, + nesting: 0, + comments: ['Comment on version 13', 'Another comment on version 13'], + lexeme: 'TAP version 13' + }, + { + kind: 'SubTestPointKeyword', + node: { name: '/test.js' }, + nesting: 0, + lexeme: '# Subtest: /test.js' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'level 0a' }, + nesting: 1, + lexeme: ' # Subtest: level 0a' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'level 1a' }, + nesting: 2, + lexeme: ' # Subtest: level 1a' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'level 1a', + reason: '', + time: 1.676996, + diagnostics: ['duration_ms: 1.676996'] + }, + nesting: 3, + comments: ['Comment test point 1a', 'Comment test point 1aa'], + lexeme: ' ok 1 - level 1a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 3, + comments: ['Comment plan 1a', 'Comment plan 1aa'], + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: 'level 1a', + reason: '', + time: 0.122839, + diagnostics: [ + 'duration_ms: 0.122839', + "failureType: 'testCodeFailure'", + "error: 'level 0b error'", + "code: 'ERR_TEST_FAILURE'", + 'stack: |-', + ' TestContext. (/test.js:23:9)' + ] + }, + nesting: 2, + comments: [ + 'Comment closing test point 1a', + 'Comment closing test point 1aa' + ], + lexeme: ' not ok 1 - level 1a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 2, + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: 'level 0a', + reason: '', + time: 84.920487, + diagnostics: [ + 'duration_ms: 84.920487', + "failureType: 'subtestsFailed'", + 'exitCode: 1', + "error: '3 subtests failed'", + "code: 'ERR_TEST_FAILURE'" + ] + }, + nesting: 1, + lexeme: ' not ok 1 - level 0a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 1, + comments: ['Comment plan 0a', 'Comment plan 0aa'], + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: '/test.js', + reason: '', + time: 0, + diagnostics: [] + }, + nesting: 0, + comments: [ + 'Comment closing test point 0a', + 'Comment closing test point 0aa' + ], + lexeme: 'not ok 1 - /test.js' + }, + { + kind: 'Comment', + node: { comment: 'tests 1' }, + nesting: 0, + lexeme: '# tests 1' + }, + { + kind: 'Comment', + node: { comment: 'pass 0' }, + nesting: 0, + lexeme: '# pass 0' + }, + { + kind: 'Comment', + node: { comment: 'fail 1' }, + nesting: 0, + lexeme: '# fail 1' + }, + { + kind: 'Comment', + node: { comment: 'cancelled 0' }, + nesting: 0, + lexeme: '# cancelled 0' + }, + { + kind: 'Comment', + node: { comment: 'skipped 0' }, + nesting: 0, + lexeme: '# skipped 0' + }, + { + kind: 'Comment', + node: { comment: 'todo 0' }, + nesting: 0, + lexeme: '# todo 0' + }, + { + kind: 'Comment', + node: { comment: 'duration_ms 87.077507' }, + nesting: 0, + lexeme: '# duration_ms 87.077507' + } + ]) +}