407 lines
10 KiB
TypeScript
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;
|
|
}
|
|
} |