tstest/ts/tstest.classes.tap.parser.ts

517 lines
17 KiB
TypeScript

import { ChildProcess } from 'child_process';
import { coloredString as cs } from '@push.rocks/consolecolor';
// ============
// combines different tap test files to an overall result
// ============
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[] = [];
expectedTests: number = 0;
receivedTests: number = 0;
activeTapTestResult: TapTestResult;
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();
}
/**
* Handle test file timeout
*/
public handleTimeout(timeoutSeconds: number) {
// If no tests have been defined yet, set expected to 1
if (this.expectedTests === 0) {
this.expectedTests = 1;
}
// Create a fake failing test result for timeout
this._getNewTapTestResult();
this.activeTapTestResult.testOk = false;
this.activeTapTestResult.testSettled = true;
this.testStore.push(this.activeTapTestResult);
// Log the timeout error
if (this.logger) {
// First log the test result
this.logger.testResult(
`Test file timeout`,
false,
timeoutSeconds * 1000,
`Error: Test file exceeded timeout of ${timeoutSeconds} seconds`
);
this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`);
}
// Don't call evaluateFinalResult here, let the caller handle it
}
private _getNewTapTestResult() {
this.activeTapTestResult = new TapTestResult(this.testStore.length + 1);
}
private _processLog(logChunk: Buffer | string) {
if (Buffer.isBuffer(logChunk)) {
logChunk = logChunk.toString();
}
const logLineArray = logChunk.split('\n');
if (logLineArray[logLineArray.length - 1] === '') {
logLineArray.pop();
}
// Process each line through the protocol parser
for (const logLine of logLineArray) {
const messages = this.protocolParser.parseLine(logLine);
if (messages.length > 0) {
// Handle protocol messages
for (const message of messages) {
this._handleProtocolMessage(message, logLine);
}
} else {
// Not a protocol message, handle as console output
if (this.activeTapTestResult) {
this.activeTapTestResult.addLogLine(logLine);
}
// 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: any) {
if (this.logger) {
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
}
}
} else if (this.logger) {
// This is console output from the test file
this.logger.testConsoleOutput(logLine);
}
}
}
}
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.tapOutput(`Skipping all tests: ${plan.skipAll}`);
}
} 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
*/
public getUncompletedTests() {
// TODO:
}
/**
* returns all tests that threw an error
*/
public getErrorTests() {
return this.testStore.filter((tapTestArg) => {
return !tapTestArg.testOk;
});
}
/**
* returns a test overview as string
*/
getTestOverviewAsString() {
let overviewString = '';
for (const test of this.testStore) {
if (overviewString !== '') {
overviewString += ' | ';
}
if (test.testOk) {
overviewString += cs(`T${test.id} ${plugins.figures.tick}`, 'green');
} else {
overviewString += cs(`T${test.id} ${plugins.figures.cross}`, 'red');
}
}
return overviewString;
}
/**
* handles a tap process
* @param childProcessArg
*/
public async handleTapProcess(childProcessArg: ChildProcess) {
const done = plugins.smartpromise.defer();
childProcessArg.stdout.on('data', (data) => {
this._processLog(data);
});
childProcessArg.stderr.on('data', (data) => {
this._processLog(data);
});
childProcessArg.on('exit', async () => {
await this.evaluateFinalResult();
done.resolve();
});
await done.promise;
}
public async handleTapLog(tapLog: string) {
this._processLog(tapLog);
}
/**
* Handle snapshot data from the test
*/
private async handleSnapshot(snapshotData: { path: string; content: string; action: string }) {
try {
const smartfile = await import('@push.rocks/smartfile');
if (snapshotData.action === 'compare') {
// Try to read existing snapshot
try {
const existingSnapshot = await smartfile.fs.toStringSync(snapshotData.path);
if (existingSnapshot !== snapshotData.content) {
// Snapshot mismatch
if (this.logger) {
this.logger.testConsoleOutput(`Snapshot mismatch: ${snapshotData.path}`);
this.logger.testConsoleOutput(`Expected:\n${existingSnapshot}`);
this.logger.testConsoleOutput(`Received:\n${snapshotData.content}`);
}
// TODO: Communicate failure back to the test
} else {
if (this.logger) {
this.logger.testConsoleOutput(`Snapshot matched: ${snapshotData.path}`);
}
}
} catch (error: any) {
if (error.code === 'ENOENT') {
// Snapshot doesn't exist, create it
const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
await smartfile.fs.ensureDir(dirPath);
await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
if (this.logger) {
this.logger.testConsoleOutput(`Snapshot created: ${snapshotData.path}`);
}
} else {
throw error;
}
}
} else if (snapshotData.action === 'update') {
// Update snapshot
const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
await smartfile.fs.ensureDir(dirPath);
await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
if (this.logger) {
this.logger.testConsoleOutput(`Snapshot updated: ${snapshotData.path}`);
}
}
} catch (error: any) {
if (this.logger) {
this.logger.testConsoleOutput(`Error handling snapshot: ${error.message}`);
}
}
}
public async evaluateFinalResult() {
this.receivedTests = this.testStore.length;
// check wether all tests ran
if (this.expectedTests === this.receivedTests) {
if (this.logger) {
this.logger.tapOutput(`${this.receivedTests} out of ${this.expectedTests} Tests completed!`);
}
} else {
if (this.logger) {
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
}
}
if (!this.expectedTests && this.receivedTests === 0) {
if (this.logger) {
this.logger.error('No tests were defined. Therefore the testfile failed!');
this.logger.testFileEnd(0, 1, 0); // Count as 1 failure
}
} else if (this.expectedTests !== this.receivedTests) {
if (this.logger) {
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
const errorCount = this.getErrorTests().length || 1; // At least 1 error
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0);
}
} else if (this.getErrorTests().length === 0) {
if (this.logger) {
this.logger.tapOutput('All tests are successfull!!!');
this.logger.testFileEnd(this.receivedTests, 0, 0);
}
} else {
if (this.logger) {
this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0);
}
}
}
}