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

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tstest',
version: '2.0.0',
version: '2.1.0',
description: 'a test utility to run tests that match test/**/*.ts'
}

View File

@ -8,28 +8,27 @@ import * as plugins from './tstest.plugins.js';
import { TapTestResult } from './tstest.classes.tap.testresult.js';
import * as logPrefixes from './tstest.logprefixes.js';
import { TsTestLogger } from './tstest.logging.js';
import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js';
import type { IProtocolMessage, ITestResult, IPlanLine, IErrorBlock, ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
export class TapParser {
testStore: TapTestResult[] = [];
expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/;
expectedTests: number;
receivedTests: number;
expectedTests: number = 0;
receivedTests: number = 0;
testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*?)(\s#\s(.*))?$/;
activeTapTestResult: TapTestResult;
collectingErrorDetails: boolean = false;
currentTestError: string[] = [];
pretaskRegex = /^::__PRETASK:(.*)$/;
private logger: TsTestLogger;
private protocolParser: ProtocolParser;
private protocolVersion: string | null = null;
/**
* the constructor for TapParser
*/
constructor(public fileName: string, logger?: TsTestLogger) {
this.logger = logger;
this.protocolParser = new ProtocolParser();
}
/**
@ -75,137 +74,299 @@ export class TapParser {
logLineArray.pop();
}
// lets parse the log information
// Process each line through the protocol parser
for (const logLine of logLineArray) {
let logLineIsTapProtocol = false;
if (!this.expectedTests && this.expectedTestsRegex.test(logLine)) {
logLineIsTapProtocol = true;
const regexResult = this.expectedTestsRegex.exec(logLine);
this.expectedTests = parseInt(regexResult[2]);
if (this.logger) {
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
const messages = this.protocolParser.parseLine(logLine);
if (messages.length > 0) {
// Handle protocol messages
for (const message of messages) {
this._handleProtocolMessage(message, logLine);
}
// initiating first TapResult
this._getNewTapTestResult();
} else if (this.pretaskRegex.test(logLine)) {
logLineIsTapProtocol = true;
const pretaskContentMatch = this.pretaskRegex.exec(logLine);
if (pretaskContentMatch && pretaskContentMatch[1]) {
if (this.logger) {
this.logger.tapOutput(`Pretask -> ${pretaskContentMatch[1]}: Success.`);
}
}
} else if (this.testStatusRegex.test(logLine)) {
logLineIsTapProtocol = true;
const regexResult = this.testStatusRegex.exec(logLine);
// const testId = parseInt(regexResult[2]); // Currently unused
const testOk = (() => {
if (regexResult[1] === 'ok') {
return true;
}
return false;
})();
const testSubject = regexResult[3].trim();
const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason"
let testDuration = 0;
if (testMetadata) {
const timeMatch = testMetadata.match(/time=(\d+)ms/);
// const skipMatch = testMetadata.match(/SKIP\s*(.*)/); // Currently unused
// const todoMatch = testMetadata.match(/TODO\s*(.*)/); // Currently unused
if (timeMatch) {
testDuration = parseInt(timeMatch[1]);
}
// Skip/todo handling could be added here in the future
}
// test for protocol error - disabled as it's not critical
// The test ID mismatch can occur when tests are filtered, skipped, or use todo
// if (testId !== this.activeTapTestResult.id) {
// if (this.logger) {
// this.logger.error('Something is strange! Test Ids are not equal!');
// }
// }
this.activeTapTestResult.setTestResult(testOk);
if (testOk) {
if (this.logger) {
this.logger.testResult(testSubject, true, testDuration);
}
} else {
// Start collecting error details for failed test
this.collectingErrorDetails = true;
this.currentTestError = [];
if (this.logger) {
this.logger.testResult(testSubject, false, testDuration);
}
}
}
if (!logLineIsTapProtocol) {
} else {
// Not a protocol message, handle as console output
if (this.activeTapTestResult) {
this.activeTapTestResult.addLogLine(logLine);
}
// Check for snapshot communication
// Check for snapshot communication (legacy)
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
if (snapshotMatch) {
const base64Data = snapshotMatch[1];
try {
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
this.handleSnapshot(snapshotData);
} catch (error) {
} catch (error: any) {
if (this.logger) {
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
}
}
} else {
// Check if we're collecting error details
if (this.collectingErrorDetails) {
// Check if this line is an error detail (starts with Error: or has stack trace characteristics)
if (logLine.trim().startsWith('Error:') || logLine.trim().match(/^\s*at\s/)) {
this.currentTestError.push(logLine);
} else if (this.currentTestError.length > 0) {
// End of error details, show the error
const errorMessage = this.currentTestError.join('\n');
if (this.logger) {
this.logger.testErrorDetails(errorMessage);
}
this.collectingErrorDetails = false;
this.currentTestError = [];
}
}
// Don't output TAP error details as console output when we're collecting them
if (!this.collectingErrorDetails || (!logLine.trim().startsWith('Error:') && !logLine.trim().match(/^\s*at\s/))) {
if (this.logger) {
// This is console output from the test file, not TAP protocol
this.logger.testConsoleOutput(logLine);
}
}
} else if (this.logger) {
// This is console output from the test file
this.logger.testConsoleOutput(logLine);
}
}
}
}
if (this.activeTapTestResult && this.activeTapTestResult.testSettled) {
// Ensure any pending error is shown before settling the test
if (this.collectingErrorDetails && this.currentTestError.length > 0) {
const errorMessage = this.currentTestError.join('\n');
private _handleProtocolMessage(message: IProtocolMessage, originalLine: string) {
switch (message.type) {
case 'protocol':
this.protocolVersion = message.content.version;
if (this.logger) {
this.logger.tapOutput(`Protocol version: ${this.protocolVersion}`);
}
break;
case 'version':
// TAP version, we can ignore this
break;
case 'plan':
const plan = message.content as IPlanLine;
this.expectedTests = plan.end - plan.start + 1;
if (plan.skipAll) {
if (this.logger) {
this.logger.testErrorDetails(errorMessage);
this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`);
}
this.collectingErrorDetails = false;
this.currentTestError = [];
} else {
if (this.logger) {
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
}
}
// Initialize first TapResult
this._getNewTapTestResult();
break;
case 'test':
const testResult = message.content as ITestResult;
// Update active test result
this.activeTapTestResult.setTestResult(testResult.ok);
// Extract test duration from metadata
let testDuration = 0;
if (testResult.metadata?.time) {
testDuration = testResult.metadata.time;
}
// Log test result
if (this.logger) {
if (testResult.ok) {
this.logger.testResult(testResult.description, true, testDuration);
} else {
this.logger.testResult(testResult.description, false, testDuration);
// If there's error metadata, show it
if (testResult.metadata?.error) {
const error = testResult.metadata.error;
let errorDetails = error.message;
if (error.stack) {
errorDetails = error.stack;
}
this.logger.testErrorDetails(errorDetails);
}
}
}
// Handle directives (skip/todo)
if (testResult.directive) {
if (this.logger) {
if (testResult.directive.type === 'skip') {
this.logger.testConsoleOutput(`Test skipped: ${testResult.directive.reason || 'No reason given'}`);
} else if (testResult.directive.type === 'todo') {
this.logger.testConsoleOutput(`Test todo: ${testResult.directive.reason || 'No reason given'}`);
}
}
}
// Mark test as settled and move to next
this.activeTapTestResult.testSettled = true;
this.testStore.push(this.activeTapTestResult);
this._getNewTapTestResult();
break;
case 'comment':
if (this.logger) {
// Check if it's a pretask comment
const pretaskMatch = message.content.match(/^Pretask -> (.+): Success\.$/);
if (pretaskMatch) {
this.logger.tapOutput(message.content);
} else {
this.logger.testConsoleOutput(message.content);
}
}
break;
case 'bailout':
if (this.logger) {
this.logger.error(`Bail out! ${message.content}`);
}
break;
case 'error':
const errorBlock = message.content as IErrorBlock;
if (this.logger && errorBlock.error) {
let errorDetails = errorBlock.error.message;
if (errorBlock.error.stack) {
errorDetails = errorBlock.error.stack;
}
this.logger.testErrorDetails(errorDetails);
}
break;
case 'snapshot':
// Handle new protocol snapshot format
const snapshot = message.content;
this.handleSnapshot({
path: snapshot.name,
content: typeof snapshot.content === 'string' ? snapshot.content : JSON.stringify(snapshot.content),
action: 'compare' // Default action
});
break;
case 'event':
const event = message.content as ITestEvent;
this._handleTestEvent(event);
break;
}
}
private _handleTestEvent(event: ITestEvent) {
if (!this.logger) return;
switch (event.eventType) {
case 'test:queued':
// We can track queued tests if needed
break;
case 'test:started':
this.logger.testConsoleOutput(cs(`Test starting: ${event.data.description}`, 'cyan'));
if (event.data.retry) {
this.logger.testConsoleOutput(cs(` Retry attempt ${event.data.retry}`, 'orange'));
}
break;
case 'test:progress':
if (event.data.progress !== undefined) {
this.logger.testConsoleOutput(cs(` Progress: ${event.data.progress}%`, 'cyan'));
}
break;
case 'test:completed':
// Test completion is already handled by the test result
// This event provides additional timing info if needed
break;
case 'suite:started':
this.logger.testConsoleOutput(cs(`\nSuite: ${event.data.suiteName}`, 'blue'));
break;
case 'suite:completed':
this.logger.testConsoleOutput(cs(`Suite completed: ${event.data.suiteName}\n`, 'blue'));
break;
case 'hook:started':
this.logger.testConsoleOutput(cs(` Hook: ${event.data.hookName}`, 'cyan'));
break;
case 'hook:completed':
// Silent unless there's an error
if (event.data.error) {
this.logger.testConsoleOutput(cs(` Hook failed: ${event.data.hookName}`, 'red'));
}
break;
case 'assertion:failed':
// Enhanced assertion failure with diff
if (event.data.error) {
this._displayAssertionError(event.data.error);
}
break;
}
}
private _displayAssertionError(error: any) {
if (!this.logger) return;
// Display error message
if (error.message) {
this.logger.testErrorDetails(error.message);
}
// Display visual diff if available
if (error.diff) {
this._displayDiff(error.diff, error.expected, error.actual);
}
}
private _displayDiff(diff: any, expected: any, actual: any) {
if (!this.logger) return;
this.logger.testConsoleOutput(cs('\n Diff:', 'cyan'));
switch (diff.type) {
case 'string':
this._displayStringDiff(diff.changes);
break;
case 'object':
this._displayObjectDiff(diff.changes, expected, actual);
break;
case 'array':
this._displayArrayDiff(diff.changes, expected, actual);
break;
case 'primitive':
this._displayPrimitiveDiff(diff.changes);
break;
}
}
private _displayStringDiff(changes: any[]) {
for (const change of changes) {
const linePrefix = ` Line ${change.line + 1}: `;
if (change.type === 'add') {
this.logger.testConsoleOutput(cs(`${linePrefix}+ ${change.content}`, 'green'));
} else if (change.type === 'remove') {
this.logger.testConsoleOutput(cs(`${linePrefix}- ${change.content}`, 'red'));
}
}
}
private _displayObjectDiff(changes: any[], expected: any, actual: any) {
this.logger.testConsoleOutput(cs(' Expected:', 'red'));
this.logger.testConsoleOutput(` ${JSON.stringify(expected, null, 2)}`);
this.logger.testConsoleOutput(cs(' Actual:', 'green'));
this.logger.testConsoleOutput(` ${JSON.stringify(actual, null, 2)}`);
this.logger.testConsoleOutput(cs('\n Changes:', 'cyan'));
for (const change of changes) {
const path = change.path.join('.');
if (change.type === 'add') {
this.logger.testConsoleOutput(cs(` + ${path}: ${JSON.stringify(change.newValue)}`, 'green'));
} else if (change.type === 'remove') {
this.logger.testConsoleOutput(cs(` - ${path}: ${JSON.stringify(change.oldValue)}`, 'red'));
} else if (change.type === 'modify') {
this.logger.testConsoleOutput(cs(` ~ ${path}:`, 'cyan'));
this.logger.testConsoleOutput(cs(` - ${JSON.stringify(change.oldValue)}`, 'red'));
this.logger.testConsoleOutput(cs(` + ${JSON.stringify(change.newValue)}`, 'green'));
}
}
}
private _displayArrayDiff(changes: any[], expected: any[], actual: any[]) {
this._displayObjectDiff(changes, expected, actual);
}
private _displayPrimitiveDiff(changes: any[]) {
const change = changes[0];
if (change) {
this.logger.testConsoleOutput(cs(` Expected: ${JSON.stringify(change.oldValue)}`, 'red'));
this.logger.testConsoleOutput(cs(` Actual: ${JSON.stringify(change.newValue)}`, 'green'));
}
}
/**
* returns all tests that are not completed
@ -353,4 +514,4 @@ export class TapParser {
}
}
}
}
}

View File

@ -161,9 +161,45 @@ export class TsTest {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(
`tsrun ${fileNameArg}${tsrunOptions}`
);
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(fileNameArg);
const initFile = plugins.path.join(testDir, '00init.ts');
let runCommand = `tsrun ${fileNameArg}${tsrunOptions}`;
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
// If 00init.ts exists, run it first
if (initFileExists) {
// Create a temporary loader file that imports both 00init.ts and the test file
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(fileNameArg);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (initFileExists) {
const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`);
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
@ -382,10 +418,10 @@ export class TsTest {
try {
// Delete 00err and 00diff directories if they exist
if (await plugins.smartfile.fs.isDirectory(errDir)) {
await plugins.smartfile.fs.remove(errDir);
plugins.smartfile.fs.removeSync(errDir);
}
if (await plugins.smartfile.fs.isDirectory(diffDir)) {
await plugins.smartfile.fs.remove(diffDir);
plugins.smartfile.fs.removeSync(diffDir);
}
// Get all .log files in log directory (not in subdirectories)