tstest/ts_tapbundle_protocol/protocol.parser.ts

407 lines
10 KiB
TypeScript

import type {
ITestResult,
ITestMetadata,
IPlanLine,
IProtocolMessage,
ISnapshotData,
IErrorBlock,
ITestEvent
} from './protocol.types.js';
import {
PROTOCOL_MARKERS
} from './protocol.types.js';
/**
* ProtocolParser parses Protocol V2 messages
* This class is used by tstest to parse test results from the new protocol format
*/
export class ProtocolParser {
private protocolVersion: string | null = null;
private inBlock = false;
private blockType: string | null = null;
private blockContent: string[] = [];
/**
* Parse a single line and return protocol messages
*/
public parseLine(line: string): IProtocolMessage[] {
const messages: IProtocolMessage[] = [];
// Handle block content
if (this.inBlock) {
if (this.isBlockEnd(line)) {
messages.push(this.finalizeBlock());
this.inBlock = false;
this.blockType = null;
this.blockContent = [];
} else {
this.blockContent.push(line);
}
return messages;
}
// Check for block start
if (this.isBlockStart(line)) {
this.inBlock = true;
this.blockType = this.extractBlockType(line);
return messages;
}
// Check for protocol version
const protocolVersion = this.parseProtocolVersion(line);
if (protocolVersion) {
this.protocolVersion = protocolVersion;
messages.push({
type: 'protocol',
content: { version: protocolVersion }
});
return messages;
}
// Parse TAP version
const tapVersion = this.parseTapVersion(line);
if (tapVersion !== null) {
messages.push({
type: 'version',
content: tapVersion
});
return messages;
}
// Parse plan
const plan = this.parsePlan(line);
if (plan) {
messages.push({
type: 'plan',
content: plan
});
return messages;
}
// Parse bailout
const bailout = this.parseBailout(line);
if (bailout) {
messages.push({
type: 'bailout',
content: bailout
});
return messages;
}
// Parse comment
if (this.isComment(line)) {
messages.push({
type: 'comment',
content: line.substring(2) // Remove "# "
});
return messages;
}
// Parse test result
const testResult = this.parseTestResult(line);
if (testResult) {
messages.push({
type: 'test',
content: testResult
});
return messages;
}
// Parse event
const event = this.parseEvent(line);
if (event) {
messages.push({
type: 'event',
content: event
});
return messages;
}
return messages;
}
/**
* Parse protocol version header
*/
private parseProtocolVersion(line: string): string | null {
const match = this.extractProtocolData(line, PROTOCOL_MARKERS.PROTOCOL_PREFIX);
return match;
}
/**
* Parse TAP version line
*/
private parseTapVersion(line: string): number | null {
const match = line.match(/^TAP version (\d+)$/);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
/**
* Parse plan line
*/
private parsePlan(line: string): IPlanLine | null {
// Skip all plan
const skipMatch = line.match(/^1\.\.0\s*#\s*Skipped:\s*(.*)$/);
if (skipMatch) {
return {
start: 1,
end: 0,
skipAll: skipMatch[1]
};
}
// Normal plan
const match = line.match(/^(\d+)\.\.(\d+)$/);
if (match) {
return {
start: parseInt(match[1], 10),
end: parseInt(match[2], 10)
};
}
return null;
}
/**
* Parse bailout
*/
private parseBailout(line: string): string | null {
const match = line.match(/^Bail out!\s*(.*)$/);
return match ? match[1] : null;
}
/**
* Parse event
*/
private parseEvent(line: string): ITestEvent | null {
const eventData = this.extractProtocolData(line, PROTOCOL_MARKERS.EVENT_PREFIX);
if (eventData) {
try {
return JSON.parse(eventData) as ITestEvent;
} catch (e) {
// Invalid JSON, ignore
return null;
}
}
return null;
}
/**
* Check if line is a comment
*/
private isComment(line: string): boolean {
return line.startsWith('# ');
}
/**
* Parse test result line
*/
private parseTestResult(line: string): ITestResult | null {
// First extract any inline metadata
const metadata = this.extractInlineMetadata(line);
const cleanLine = this.removeInlineMetadata(line);
// Parse the TAP part
const tapMatch = cleanLine.match(/^(ok|not ok)\s+(\d+)\s*-?\s*(.*)$/);
if (!tapMatch) {
return null;
}
const result: ITestResult = {
ok: tapMatch[1] === 'ok',
testNumber: parseInt(tapMatch[2], 10),
description: tapMatch[3].trim()
};
// Parse directive
const directiveMatch = result.description.match(/^(.*?)\s*#\s*(SKIP|TODO)\s*(.*)$/i);
if (directiveMatch) {
result.description = directiveMatch[1].trim();
result.directive = {
type: directiveMatch[2].toLowerCase() as 'skip' | 'todo',
reason: directiveMatch[3] || undefined
};
}
// Add metadata if found
if (metadata) {
result.metadata = metadata;
}
return result;
}
/**
* Extract inline metadata from line
*/
private extractInlineMetadata(line: string): ITestMetadata | null {
const metadata: ITestMetadata = {};
let hasData = false;
// Extract skip reason
const skipData = this.extractProtocolData(line, PROTOCOL_MARKERS.SKIP_PREFIX);
if (skipData) {
metadata.skip = skipData;
hasData = true;
}
// Extract todo reason
const todoData = this.extractProtocolData(line, PROTOCOL_MARKERS.TODO_PREFIX);
if (todoData) {
metadata.todo = todoData;
hasData = true;
}
// Extract META JSON
const metaData = this.extractProtocolData(line, PROTOCOL_MARKERS.META_PREFIX);
if (metaData) {
try {
Object.assign(metadata, JSON.parse(metaData));
hasData = true;
} catch (e) {
// Invalid JSON, ignore
}
}
// Extract simple key:value pairs
const simpleMatch = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]+)${this.escapeRegex(PROTOCOL_MARKERS.END)}`));
if (simpleMatch && simpleMatch[1].includes(':') && !simpleMatch[1].includes('META:') && !simpleMatch[1].includes('SKIP:') && !simpleMatch[1].includes('TODO:') && !simpleMatch[1].includes('EVENT:')) {
// This is a simple key:value format (not a prefixed format)
const pairs = simpleMatch[1].split(',');
for (const pair of pairs) {
const [key, value] = pair.split(':');
if (key && value) {
if (key === 'time') {
metadata.time = parseInt(value, 10);
hasData = true;
} else if (key === 'retry') {
metadata.retry = parseInt(value, 10);
hasData = true;
}
}
}
}
return hasData ? metadata : null;
}
/**
* Remove inline metadata from line
*/
private removeInlineMetadata(line: string): string {
// Remove all protocol markers
const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}[^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*${this.escapeRegex(PROTOCOL_MARKERS.END)}`, 'g');
return line.replace(regex, '').trim();
}
/**
* Extract protocol data with specific prefix
*/
private extractProtocolData(line: string, prefix: string): string | null {
const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(prefix)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`);
const match = line.match(regex);
return match ? match[1] : null;
}
/**
* Check if line starts a block
*/
private isBlockStart(line: string): boolean {
// Only match if the line is exactly the block marker (after trimming)
const trimmed = line.trim();
return trimmed === `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_PREFIX}${PROTOCOL_MARKERS.END}` ||
(trimmed.startsWith(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_PREFIX}`) &&
trimmed.endsWith(PROTOCOL_MARKERS.END) &&
!trimmed.includes(' '));
}
/**
* Check if line ends a block
*/
private isBlockEnd(line: string): boolean {
return line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_END}${PROTOCOL_MARKERS.END}`) ||
line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_END}${PROTOCOL_MARKERS.END}`);
}
/**
* Extract block type from start line
*/
private extractBlockType(line: string): string | null {
if (line.includes(PROTOCOL_MARKERS.ERROR_PREFIX)) {
return 'error';
}
if (line.includes(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)) {
const match = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`));
return match ? `snapshot:${match[1]}` : 'snapshot';
}
return null;
}
/**
* Finalize current block
*/
private finalizeBlock(): IProtocolMessage {
const content = this.blockContent.join('\n');
if (this.blockType === 'error') {
try {
const errorData = JSON.parse(content) as IErrorBlock;
return {
type: 'error',
content: errorData
};
} catch (e) {
return {
type: 'error',
content: { error: { message: content } }
};
}
}
if (this.blockType?.startsWith('snapshot:')) {
const name = this.blockType.substring(9);
let parsedContent = content;
let format: 'json' | 'text' = 'text';
try {
parsedContent = JSON.parse(content);
format = 'json';
} catch (e) {
// Not JSON, keep as text
}
return {
type: 'snapshot',
content: {
name,
content: parsedContent,
format
} as ISnapshotData
};
}
// Fallback
return {
type: 'comment',
content: content
};
}
/**
* Escape regex special characters
*/
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Get protocol version
*/
public getProtocolVersion(): string | null {
return this.protocolVersion;
}
}