feat(core): Implement Protocol V2 with enhanced settings and lifecycle hooks

This commit is contained in:
2025-05-26 04:02:32 +00:00
parent 91880f8d42
commit 33d2ff1d4f
24 changed files with 2356 additions and 441 deletions

View File

@ -0,0 +1,13 @@
// Protocol V2 - Isomorphic implementation for improved TAP protocol
// This module is designed to work in both browser and Node.js environments
export * from './protocol.types.js';
export * from './protocol.emitter.js';
export * from './protocol.parser.js';
// Re-export main classes for convenience
export { ProtocolEmitter } from './protocol.emitter.js';
export { ProtocolParser } from './protocol.parser.js';
// Re-export constants
export { PROTOCOL_MARKERS, PROTOCOL_VERSION } from './protocol.types.js';

View File

@ -0,0 +1,196 @@
import type {
ITestResult,
ITestMetadata,
IPlanLine,
ISnapshotData,
IErrorBlock,
ITestEvent
} from './protocol.types.js';
import {
PROTOCOL_MARKERS,
PROTOCOL_VERSION
} from './protocol.types.js';
/**
* ProtocolEmitter generates Protocol V2 messages
* This class is used by tapbundle to emit test results in the new protocol format
*/
export class ProtocolEmitter {
/**
* Emit protocol version header
*/
public emitProtocolHeader(): string {
return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.PROTOCOL_PREFIX}${PROTOCOL_VERSION}${PROTOCOL_MARKERS.END}`;
}
/**
* Emit TAP version line
*/
public emitTapVersion(version: number = 13): string {
return `TAP version ${version}`;
}
/**
* Emit test plan
*/
public emitPlan(plan: IPlanLine): string {
if (plan.skipAll) {
return `1..0 # Skipped: ${plan.skipAll}`;
}
return `${plan.start}..${plan.end}`;
}
/**
* Emit a test result
*/
public emitTest(result: ITestResult): string[] {
const lines: string[] = [];
// Build the basic TAP line
let tapLine = result.ok ? 'ok' : 'not ok';
tapLine += ` ${result.testNumber}`;
tapLine += ` - ${result.description}`;
// Add directive if present
if (result.directive) {
tapLine += ` # ${result.directive.type.toUpperCase()}`;
if (result.directive.reason) {
tapLine += ` ${result.directive.reason}`;
}
}
// Add inline metadata for simple cases
if (result.metadata && this.shouldUseInlineMetadata(result.metadata)) {
const metaStr = this.createInlineMetadata(result.metadata);
if (metaStr) {
tapLine += ` ${metaStr}`;
}
}
lines.push(tapLine);
// Add block metadata for complex cases
if (result.metadata && !this.shouldUseInlineMetadata(result.metadata)) {
lines.push(...this.createBlockMetadata(result.metadata, result.testNumber));
}
return lines;
}
/**
* Emit a comment line
*/
public emitComment(comment: string): string {
return `# ${comment}`;
}
/**
* Emit bailout
*/
public emitBailout(reason: string): string {
return `Bail out! ${reason}`;
}
/**
* Emit snapshot data
*/
public emitSnapshot(snapshot: ISnapshotData): string[] {
const lines: string[] = [];
lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_PREFIX}${snapshot.name}${PROTOCOL_MARKERS.END}`);
if (snapshot.format === 'json') {
lines.push(JSON.stringify(snapshot.content, null, 2));
} else {
lines.push(String(snapshot.content));
}
lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_END}${PROTOCOL_MARKERS.END}`);
return lines;
}
/**
* Emit error block
*/
public emitError(error: IErrorBlock): string[] {
const lines: string[] = [];
lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_PREFIX}${PROTOCOL_MARKERS.END}`);
lines.push(JSON.stringify(error, null, 2));
lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_END}${PROTOCOL_MARKERS.END}`);
return lines;
}
/**
* Emit test event
*/
public emitEvent(event: ITestEvent): string {
const eventJson = JSON.stringify(event);
return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.EVENT_PREFIX}${eventJson}${PROTOCOL_MARKERS.END}`;
}
/**
* Check if metadata should be inline
*/
private shouldUseInlineMetadata(metadata: ITestMetadata): boolean {
// Use inline for simple metadata (time, retry, simple skip/todo)
const hasComplexData = metadata.error ||
metadata.custom ||
(metadata.tags && metadata.tags.length > 0) ||
metadata.file ||
metadata.line;
return !hasComplexData;
}
/**
* Create inline metadata string
*/
private createInlineMetadata(metadata: ITestMetadata): string {
const parts: string[] = [];
if (metadata.time !== undefined) {
parts.push(`time:${metadata.time}`);
}
if (metadata.retry !== undefined) {
parts.push(`retry:${metadata.retry}`);
}
if (metadata.skip) {
return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SKIP_PREFIX}${metadata.skip}${PROTOCOL_MARKERS.END}`;
}
if (metadata.todo) {
return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.TODO_PREFIX}${metadata.todo}${PROTOCOL_MARKERS.END}`;
}
if (parts.length > 0) {
return `${PROTOCOL_MARKERS.START}${parts.join(',')}${PROTOCOL_MARKERS.END}`;
}
return '';
}
/**
* Create block metadata lines
*/
private createBlockMetadata(metadata: ITestMetadata, testNumber?: number): string[] {
const lines: string[] = [];
// Create a clean metadata object without skip/todo (handled inline)
const blockMeta = { ...metadata };
delete blockMeta.skip;
delete blockMeta.todo;
// Emit metadata block
const metaJson = JSON.stringify(blockMeta);
lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.META_PREFIX}${metaJson}${PROTOCOL_MARKERS.END}`);
// Emit separate error block if present
if (metadata.error) {
lines.push(...this.emitError({ testNumber, error: metadata.error }));
}
return lines;
}
}

View File

@ -0,0 +1,407 @@
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(':')) {
// Not a prefixed format, might be key:value pairs
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;
}
}

View File

@ -0,0 +1,148 @@
// Protocol V2 Types and Interfaces
// This file contains all type definitions for the improved TAP protocol
export interface ITestMetadata {
// Timing
time?: number; // milliseconds
startTime?: number; // Unix timestamp
endTime?: number; // Unix timestamp
// Status
skip?: string; // skip reason
todo?: string; // todo reason
retry?: number; // retry attempt
maxRetries?: number; // max retries allowed
// Error details
error?: {
message: string;
stack?: string;
diff?: string;
actual?: any;
expected?: any;
code?: string;
};
// Test context
file?: string; // source file
line?: number; // line number
column?: number; // column number
// Custom data
tags?: string[]; // test tags
custom?: Record<string, any>;
}
export interface ITestResult {
ok: boolean;
testNumber: number;
description: string;
directive?: {
type: 'skip' | 'todo';
reason?: string;
};
metadata?: ITestMetadata;
}
export interface IPlanLine {
start: number;
end: number;
skipAll?: string;
}
export interface IProtocolMessage {
type: 'test' | 'plan' | 'comment' | 'version' | 'bailout' | 'protocol' | 'snapshot' | 'error' | 'event';
content: any;
}
export interface IProtocolVersion {
version: string;
features?: string[];
}
export interface ISnapshotData {
name: string;
content: any;
format?: 'json' | 'text' | 'binary';
}
export interface IErrorBlock {
testNumber?: number;
error: {
message: string;
stack?: string;
diff?: string;
actual?: any;
expected?: any;
};
}
// Enhanced Communication Types
export type EventType =
| 'test:queued'
| 'test:started'
| 'test:progress'
| 'test:completed'
| 'suite:started'
| 'suite:completed'
| 'hook:started'
| 'hook:completed'
| 'assertion:failed';
export interface ITestEvent {
eventType: EventType;
timestamp: number;
data: {
testNumber?: number;
description?: string;
suiteName?: string;
hookName?: string;
progress?: number; // 0-100
duration?: number;
error?: IEnhancedError;
[key: string]: any;
};
}
export interface IEnhancedError {
message: string;
stack?: string;
diff?: IDiffResult;
actual?: any;
expected?: any;
code?: string;
type?: 'assertion' | 'timeout' | 'uncaught' | 'syntax' | 'runtime';
}
export interface IDiffResult {
type: 'string' | 'object' | 'array' | 'primitive';
changes: IDiffChange[];
context?: number; // lines of context
}
export interface IDiffChange {
type: 'add' | 'remove' | 'modify';
path?: string[]; // for object/array diffs
oldValue?: any;
newValue?: any;
line?: number; // for string diffs
content?: string;
}
// Protocol markers
export const PROTOCOL_MARKERS = {
START: '⟦TSTEST:',
END: '⟧',
META_PREFIX: 'META:',
ERROR_PREFIX: 'ERROR',
ERROR_END: '/ERROR',
SNAPSHOT_PREFIX: 'SNAPSHOT:',
SNAPSHOT_END: '/SNAPSHOT',
PROTOCOL_PREFIX: 'PROTOCOL:',
SKIP_PREFIX: 'SKIP:',
TODO_PREFIX: 'TODO:',
EVENT_PREFIX: 'EVENT:',
} as const;
// Protocol version
export const PROTOCOL_VERSION = '2.0.0';