517 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
} |