feat(core): Implement Protocol V2 with enhanced settings and lifecycle hooks
This commit is contained in:
@ -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'
|
||||
}
|
||||
|
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Reference in New Issue
Block a user