diff --git a/doc/api/errors.md b/doc/api/errors.md
index a4f6902be32d63..75570302f58828 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -2648,6 +2648,25 @@ An unspecified or non-specific system error has occurred within the Node.js
process. The error object will have an `err.info` object property with
additional details.
+
+
+### `ERR_TAP_LEXER_ERROR`
+
+An error representing a failing lexer state.
+
+
+
+### `ERR_TAP_PARSER_ERROR`
+
+An error representing a failing parser state. Additional information about
+the token causing the error is available via the `cause` property.
+
+
+
+### `ERR_TAP_VALIDATION_ERROR`
+
+This error represents a failed TAP validation.
+
### `ERR_TEST_FAILURE`
diff --git a/doc/api/test.md b/doc/api/test.md
index 5383e3b155d073..c5f049cf43313e 100644
--- a/doc/api/test.md
+++ b/doc/api/test.md
@@ -1028,8 +1028,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
@@ -1040,7 +1039,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 a75e7ccd056889..40e1bead6af0e8 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1587,6 +1587,21 @@ E('ERR_STREAM_WRAP', 'Stream has StringDecoder set or is in objectMode', Error);
E('ERR_STREAM_WRITE_AFTER_END', 'write after end', Error);
E('ERR_SYNTHETIC', 'JavaScript Callstack', Error);
E('ERR_SYSTEM_ERROR', 'A system error occurred', SystemError);
+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/test_runner/runner.js b/lib/internal/test_runner/runner.js
index 4995a2b631c67e..5dba457305d67b 100644
--- a/lib/internal/test_runner/runner.js
+++ b/lib/internal/test_runner/runner.js
@@ -2,6 +2,7 @@
const {
ArrayFrom,
ArrayPrototypeFilter,
+ ArrayPrototypeForEach,
ArrayPrototypeIncludes,
ArrayPrototypeJoin,
ArrayPrototypePush,
@@ -14,6 +15,7 @@ const {
SafePromiseAllSettledReturnVoid,
SafeMap,
SafeSet,
+ StringPrototypeRepeat,
} = primordials;
const { spawn } = require('child_process');
@@ -31,7 +33,10 @@ const { validateArray, validateBoolean } = 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,
@@ -119,11 +124,103 @@ function getRunArgs({ path, inspectPort }) {
return argv;
}
+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();
+ }
+}
+
const runningProcesses = new SafeMap();
const runningSubtests = new SafeMap();
function runTestFile(path, root, inspectPort, filesWatcher) {
- const subtest = root.createSubtest(Test, path, async (t) => {
+ const subtest = root.createSubtest(FileTest, path, async (t) => {
const args = getRunArgs({ path, inspectPort });
const stdio = ['pipe', 'pipe', 'pipe'];
const env = { ...process.env };
@@ -134,8 +231,7 @@ function runTestFile(path, root, inspectPort, filesWatcher) {
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8', env, stdio });
runningProcesses.set(path, child);
- // 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 = '';
@@ -158,6 +254,17 @@ function runTestFile(path, root, inspectPort, filesWatcher) {
});
}
+ 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 }),
child.stdout.toArray({ 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 00000000000000..1392fb7d3bd335
--- /dev/null
+++ b/lib/internal/test_runner/tap_checker.js
@@ -0,0 +1,155 @@
+'use strict';
+
+const {
+ ArrayPrototypeFilter,
+ ArrayPrototypeFind,
+ NumberParseInt,
+} = 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 00000000000000..24743a1853a833
--- /dev/null
+++ b/lib/internal/test_runner/tap_lexer.js
@@ -0,0 +1,522 @@
+'use strict';
+
+const { SafeSet, MathMax, StringPrototypeIncludes } = 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 00000000000000..2b79220b632e02
--- /dev/null
+++ b/lib/internal/test_runner/tap_parser.js
@@ -0,0 +1,980 @@
+'use strict';
+
+const Transform = require('internal/streams/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,
+} = 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 });
+ }
+
+ #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.at(-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 ||= [];
+
+ 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.at(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 efd73e1e945995..3abd7ed5560664 100644
--- a/lib/internal/test_runner/tap_stream.js
+++ b/lib/internal/test_runner/tap_stream.js
@@ -7,6 +7,7 @@ const {
ArrayPrototypeShift,
ObjectEntries,
StringPrototypeReplaceAll,
+ StringPrototypeToUpperCase,
StringPrototypeSplit,
RegExpPrototypeSymbolReplace,
} = primordials;
@@ -15,6 +16,7 @@ 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.
@@ -50,16 +52,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) {
@@ -80,9 +82,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 ? yaml : ''}`;
details += jsToYaml(indent, 'duration_ms', duration);
details += jsToYaml(indent, null, error);
details += `${indent} ...\n`;
@@ -94,8 +98,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) {
@@ -106,10 +110,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);
}
@@ -229,7 +234,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) {
@@ -242,7 +249,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 6c874a0b05815d..30ed04cf523473 100644
--- a/lib/internal/test_runner/test.js
+++ b/lib/internal/test_runner/test.js
@@ -62,8 +62,8 @@ const kSubtestsFailed = 'subtestsFailed';
const kTestCodeFailure = 'testCodeFailure';
const kTestTimeoutFailure = 'testTimeoutFailure';
const kHookFailure = 'hookFailed';
-const kDefaultIndent = ' ';
const kDefaultTimeout = null;
+const kDefaultIndent = ' '; // 4 spaces
const noop = FunctionPrototype;
const isTestRunner = getOptionValue('--test');
const testOnlyFlag = !isTestRunner && getOptionValue('--test-only');
@@ -172,7 +172,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;
@@ -180,11 +179,10 @@ class Test extends AsyncResource {
this.timeout = kDefaultTimeout;
} 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;
@@ -662,6 +660,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);
@@ -670,9 +669,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 00000000000000..d43fb8ad5cb374
--- /dev/null
+++ b/test/fixtures/test-runner/invalid-tap.js
@@ -0,0 +1 @@
+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 00000000000000..b539f872f883da
--- /dev/null
+++ b/test/fixtures/test-runner/nested.js
@@ -0,0 +1,24 @@
+'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');
+});
\ No newline at end of file
diff --git a/test/fixtures/test-runner/run_inspect.js b/test/fixtures/test-runner/run_inspect.js
index 1586b6aaccf082..bf9e3cc7f7042f 100644
--- a/test/fixtures/test-runner/run_inspect.js
+++ b/test/fixtures/test-runner/run_inspect.js
@@ -32,8 +32,8 @@ if (process.env.inspectPort === 'addTwo') {
const stream = run({ files: [fixtures.path('test-runner/run_inspect_assert.js')], inspectPort });
if (expectedError) {
- stream.on('test:fail', common.mustCall(({ error }) => {
- assert.deepStrictEqual({ name: error.cause.name, code: error.cause.code }, expectedError);
+ stream.on('test:fail', common.mustCall(({ details }) => {
+ assert.deepStrictEqual({ name: details.error.cause.name, code: details.error.cause.code }, expectedError);
}));
} else {
stream.on('test:fail', common.mustNotCall());
diff --git a/test/parallel/test-runner-cli.js b/test/parallel/test-runner-cli.js
index 8c1f6b3b0bee69..ac8482c5ce69be 100644
--- a/test/parallel/test-runner-cli.js
+++ b/test/parallel/test-runner-cli.js
@@ -1,4 +1,5 @@
'use strict';
+
require('../common');
const assert = require('assert');
const { spawnSync } = require('child_process');
@@ -104,7 +105,6 @@ const testFixtures = fixtures.path('test-runner');
['--print', 'console.log("should not print")', '--test'],
];
-
flags.forEach((args) => {
const child = spawnSync(process.execPath, args);
@@ -115,3 +115,56 @@ 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-inspect.mjs b/test/parallel/test-runner-inspect.mjs
index 92117641870c16..bdff1ce7ceb84f 100644
--- a/test/parallel/test-runner-inspect.mjs
+++ b/test/parallel/test-runner-inspect.mjs
@@ -37,7 +37,6 @@ tmpdir.refresh();
assert.match(stderr,
/Warning: Using the inspector with --test forces running at a concurrency of 1\. Use the inspectPort option to run with concurrency/);
assert.match(stdout, /not ok 1 - .+index\.js/);
- assert.match(stdout, /stderr: \|-\r?\n\s+Debugger listening on/);
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
}
diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs
index 4a0d4f025bf7bf..8f650509f9ee54 100644
--- a/test/parallel/test-runner-run.mjs
+++ b/test/parallel/test-runner-run.mjs
@@ -28,7 +28,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
});
@@ -36,7 +36,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 00000000000000..d437b025180c78
--- /dev/null
+++ b/test/parallel/test-runner-tap-checker.js
@@ -0,0 +1,119 @@
+'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 00000000000000..7da0f7c617c121
--- /dev/null
+++ b/test/parallel/test-runner-tap-lexer.js
@@ -0,0 +1,446 @@
+'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 00000000000000..bd10af29d88279
--- /dev/null
+++ b/test/parallel/test-runner-tap-parser-stream.js
@@ -0,0 +1,629 @@
+// 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 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 parser.toArray();
+ 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 parser.toArray();
+ 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 00000000000000..530e56626a4c5b
--- /dev/null
+++ b/test/parallel/test-runner-tap-parser.js
@@ -0,0 +1,1312 @@
+'use strict';
+// Flags: --expose-internals
+
+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',
+ },
+ ]);
+}