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

@ -1,11 +1,7 @@
export { tap } from './tapbundle.classes.tap.js';
export { TapWrap } from './tapbundle.classes.tapwrap.js';
export { webhelpers } from './webhelpers.js';
// Protocol utilities (for future protocol v2)
export * from './tapbundle.protocols.js';
export { TapTools } from './tapbundle.classes.taptools.js';
import { expect } from '@push.rocks/smartexpect';
export { expect };
// Export enhanced expect with diff generation
export { expect, setProtocolEmitter } from './tapbundle.expect.wrapper.js';

View File

@ -0,0 +1,117 @@
import type { ITapSettings, ISettingsManager } from './tapbundle.interfaces.js';
export class SettingsManager implements ISettingsManager {
private globalSettings: ITapSettings = {};
private fileSettings: ITapSettings = {};
private testSettings: Map<string, ITapSettings> = new Map();
// Default settings
private defaultSettings: ITapSettings = {
timeout: undefined, // No timeout by default
slowThreshold: 1000, // 1 second
bail: false,
retries: 0,
retryDelay: 0,
suppressConsole: false,
verboseErrors: true,
showTestDuration: true,
maxConcurrency: 5,
isolateTests: false,
enableSnapshots: true,
snapshotDirectory: '.snapshots',
updateSnapshots: false,
};
/**
* Get merged settings for current context
*/
public getSettings(): ITapSettings {
return this.mergeSettings(
this.defaultSettings,
this.globalSettings,
this.fileSettings
);
}
/**
* Set global settings (from 00init.ts or tap.settings())
*/
public setGlobalSettings(settings: ITapSettings): void {
this.globalSettings = { ...this.globalSettings, ...settings };
}
/**
* Set file-level settings
*/
public setFileSettings(settings: ITapSettings): void {
this.fileSettings = { ...this.fileSettings, ...settings };
}
/**
* Set test-specific settings
*/
public setTestSettings(testId: string, settings: ITapSettings): void {
const existingSettings = this.testSettings.get(testId) || {};
this.testSettings.set(testId, { ...existingSettings, ...settings });
}
/**
* Get settings for specific test
*/
public getTestSettings(testId: string): ITapSettings {
const testSpecificSettings = this.testSettings.get(testId) || {};
return this.mergeSettings(
this.defaultSettings,
this.globalSettings,
this.fileSettings,
testSpecificSettings
);
}
/**
* Merge settings with proper inheritance
* Later settings override earlier ones
*/
private mergeSettings(...settingsArray: ITapSettings[]): ITapSettings {
const result: ITapSettings = {};
for (const settings of settingsArray) {
// Simple properties - later values override
if (settings.timeout !== undefined) result.timeout = settings.timeout;
if (settings.slowThreshold !== undefined) result.slowThreshold = settings.slowThreshold;
if (settings.bail !== undefined) result.bail = settings.bail;
if (settings.retries !== undefined) result.retries = settings.retries;
if (settings.retryDelay !== undefined) result.retryDelay = settings.retryDelay;
if (settings.suppressConsole !== undefined) result.suppressConsole = settings.suppressConsole;
if (settings.verboseErrors !== undefined) result.verboseErrors = settings.verboseErrors;
if (settings.showTestDuration !== undefined) result.showTestDuration = settings.showTestDuration;
if (settings.maxConcurrency !== undefined) result.maxConcurrency = settings.maxConcurrency;
if (settings.isolateTests !== undefined) result.isolateTests = settings.isolateTests;
if (settings.enableSnapshots !== undefined) result.enableSnapshots = settings.enableSnapshots;
if (settings.snapshotDirectory !== undefined) result.snapshotDirectory = settings.snapshotDirectory;
if (settings.updateSnapshots !== undefined) result.updateSnapshots = settings.updateSnapshots;
// Lifecycle hooks - later ones override
if (settings.beforeAll !== undefined) result.beforeAll = settings.beforeAll;
if (settings.afterAll !== undefined) result.afterAll = settings.afterAll;
if (settings.beforeEach !== undefined) result.beforeEach = settings.beforeEach;
if (settings.afterEach !== undefined) result.afterEach = settings.afterEach;
// Environment variables - merge
if (settings.env) {
result.env = { ...result.env, ...settings.env };
}
}
return result;
}
/**
* Clear all settings (useful for testing)
*/
public clearSettings(): void {
this.globalSettings = {};
this.fileSettings = {};
this.testSettings.clear();
}
}

View File

@ -2,6 +2,9 @@ import * as plugins from './tapbundle.plugins.js';
import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js';
import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
import type { ITapSettings } from './tapbundle.interfaces.js';
import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
export interface ITestSuite {
description: string;
@ -102,6 +105,8 @@ class TestBuilder<T> {
}
export class Tap<T> {
private protocolEmitter = new ProtocolEmitter();
private settingsManager = new SettingsManager();
private _skipCount = 0;
private _filterTags: string[] = [];
@ -139,12 +144,27 @@ export class Tap<T> {
*/
public skip = {
test: (descriptionArg: string, functionArg: ITestFunction<T>) => {
console.log(`skipped test: ${descriptionArg}`);
this._skipCount++;
const skippedTest = this.test(descriptionArg, functionArg, 'skip');
return skippedTest;
},
testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => {
console.log(`skipped test: ${descriptionArg}`);
this._skipCount++;
const skippedTest = new TapTest<T>({
description: descriptionArg,
testFunction: functionArg,
parallel: true,
});
// Mark as skip mode
skippedTest.tapTools.markAsSkipped('Marked as skip');
// Add to appropriate test list
if (this._currentSuite) {
this._currentSuite.tests.push(skippedTest);
} else {
this._tapTests.push(skippedTest);
}
return skippedTest;
},
};
@ -153,7 +173,65 @@ export class Tap<T> {
*/
public only = {
test: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
this.test(descriptionArg, testFunctionArg, 'only');
return this.test(descriptionArg, testFunctionArg, 'only');
},
testParallel: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
const onlyTest = new TapTest<T>({
description: descriptionArg,
testFunction: testFunctionArg,
parallel: true,
});
// Add to only tests list
this._tapTestsOnly.push(onlyTest);
return onlyTest;
},
};
/**
* mark a test as todo (not yet implemented)
*/
public todo = {
test: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
const defaultFunc = (async () => {}) as ITestFunction<T>;
const todoTest = new TapTest<T>({
description: descriptionArg,
testFunction: functionArg || defaultFunc,
parallel: false,
});
// Mark as todo
todoTest.tapTools.todo('Marked as todo');
// Add to appropriate test list
if (this._currentSuite) {
this._currentSuite.tests.push(todoTest);
} else {
this._tapTests.push(todoTest);
}
return todoTest;
},
testParallel: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
const defaultFunc = (async () => {}) as ITestFunction<T>;
const todoTest = new TapTest<T>({
description: descriptionArg,
testFunction: functionArg || defaultFunc,
parallel: true,
});
// Mark as todo
todoTest.tapTools.todo('Marked as todo');
// Add to appropriate test list
if (this._currentSuite) {
this._currentSuite.tests.push(todoTest);
} else {
this._tapTests.push(todoTest);
}
return todoTest;
},
};
@ -163,6 +241,21 @@ export class Tap<T> {
private _currentSuite: ITestSuite | null = null;
private _rootSuites: ITestSuite[] = [];
/**
* Configure global test settings
*/
public settings(settings: ITapSettings): this {
this.settingsManager.setGlobalSettings(settings);
return this;
}
/**
* Get current test settings
*/
public getSettings(): ITapSettings {
return this.settingsManager.getSettings();
}
/**
* Normal test function, will run one by one
* @param testDescription - A description of what the test does
@ -179,14 +272,26 @@ export class Tap<T> {
parallel: false,
});
// No options applied here - use the fluent builder syntax instead
// Apply default settings from settings manager
const settings = this.settingsManager.getSettings();
if (settings.timeout !== undefined) {
localTest.timeoutMs = settings.timeout;
}
if (settings.retries !== undefined) {
localTest.tapTools.retry(settings.retries);
}
// Handle skip mode
if (modeArg === 'skip') {
localTest.tapTools.markAsSkipped('Marked as skip');
}
// If we're in a suite, add test to the suite
if (this._currentSuite) {
this._currentSuite.tests.push(localTest);
} else {
// Otherwise add to global test list
if (modeArg === 'normal') {
if (modeArg === 'normal' || modeArg === 'skip') {
this._tapTests.push(localTest);
} else if (modeArg === 'only') {
this._tapTestsOnly.push(localTest);
@ -211,6 +316,15 @@ export class Tap<T> {
parallel: true,
});
// Apply default settings from settings manager
const settings = this.settingsManager.getSettings();
if (settings.timeout !== undefined) {
localTest.timeoutMs = settings.timeout;
}
if (settings.retries !== undefined) {
localTest.tapTools.retry(settings.retries);
}
if (this._currentSuite) {
this._currentSuite.tests.push(localTest);
} else {
@ -336,8 +450,27 @@ export class Tap<T> {
await preTask.run();
}
// Count actual tests that will be run
console.log(`1..${concerningTests.length}`);
// Emit protocol header and TAP version
console.log(this.protocolEmitter.emitProtocolHeader());
console.log(this.protocolEmitter.emitTapVersion(13));
// Emit test plan
const plan = {
start: 1,
end: concerningTests.length
};
console.log(this.protocolEmitter.emitPlan(plan));
// Run global beforeAll hook if configured
const settings = this.settingsManager.getSettings();
if (settings.beforeAll) {
try {
await settings.beforeAll();
} catch (error) {
console.error('Error in beforeAll hook:', error);
throw error;
}
}
// Run tests from suites with lifecycle hooks
let testKey = 0;
@ -365,6 +498,33 @@ export class Tap<T> {
});
for (const currentTest of nonSuiteTests) {
// Wrap test function with global lifecycle hooks
const originalFunction = currentTest.testFunction;
const testName = currentTest.description;
currentTest.testFunction = async (tapTools) => {
// Run global beforeEach if configured
if (settings.beforeEach) {
await settings.beforeEach(testName);
}
// Run the actual test
let testPassed = true;
let result: any;
try {
result = await originalFunction(tapTools);
} catch (error) {
testPassed = false;
throw error;
} finally {
// Run global afterEach if configured
if (settings.afterEach) {
await settings.afterEach(testName, testPassed);
}
}
return result;
};
const testPromise = currentTest.run(testKey++);
if (currentTest.parallel) {
promiseArray.push(testPromise);
@ -394,6 +554,16 @@ export class Tap<T> {
console.log(failReason);
}
// Run global afterAll hook if configured
if (settings.afterAll) {
try {
await settings.afterAll();
} catch (error) {
console.error('Error in afterAll hook:', error);
// Don't throw here, we want to complete the test run
}
}
if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) {
if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1);
}
@ -402,6 +572,13 @@ export class Tap<T> {
}
}
/**
* Emit an event
*/
private emitEvent(event: ITestEvent) {
console.log(this.protocolEmitter.emitEvent(event));
}
/**
* Run tests in a suite with lifecycle hooks
*/
@ -412,6 +589,14 @@ export class Tap<T> {
context: { testKey: number }
) {
for (const suite of suites) {
// Emit suite:started event
this.emitEvent({
eventType: 'suite:started',
timestamp: Date.now(),
data: {
suiteName: suite.description
}
});
// Run beforeEach from parent suites
const beforeEachFunctions: ITestFunction<any>[] = [];
let currentSuite: ITestSuite | null = suite;
@ -426,27 +611,46 @@ export class Tap<T> {
for (const test of suite.tests) {
// Create wrapper test function that includes lifecycle hooks
const originalFunction = test.testFunction;
const testName = test.description;
test.testFunction = async (tapTools) => {
// Run all beforeEach hooks
// Run global beforeEach if configured
const settings = this.settingsManager.getSettings();
if (settings.beforeEach) {
await settings.beforeEach(testName);
}
// Run all suite beforeEach hooks
for (const beforeEach of beforeEachFunctions) {
await beforeEach(tapTools);
}
// Run the actual test
const result = await originalFunction(tapTools);
// Run afterEach hooks in reverse order
const afterEachFunctions: ITestFunction<any>[] = [];
currentSuite = suite;
while (currentSuite) {
if (currentSuite.afterEach) {
afterEachFunctions.push(currentSuite.afterEach);
let testPassed = true;
let result: any;
try {
result = await originalFunction(tapTools);
} catch (error) {
testPassed = false;
throw error;
} finally {
// Run afterEach hooks in reverse order
const afterEachFunctions: ITestFunction<any>[] = [];
currentSuite = suite;
while (currentSuite) {
if (currentSuite.afterEach) {
afterEachFunctions.push(currentSuite.afterEach);
}
currentSuite = currentSuite.parent || null;
}
for (const afterEach of afterEachFunctions) {
await afterEach(tapTools);
}
// Run global afterEach if configured
if (settings.afterEach) {
await settings.afterEach(testName, testPassed);
}
currentSuite = currentSuite.parent || null;
}
for (const afterEach of afterEachFunctions) {
await afterEach(tapTools);
}
return result;
@ -462,6 +666,15 @@ export class Tap<T> {
// Recursively run child suites
await this._runSuite(suite, suite.children, promiseArray, context);
// Emit suite:completed event
this.emitEvent({
eventType: 'suite:completed',
timestamp: Date.now(),
data: {
suiteName: suite.description
}
});
}
}

View File

@ -1,6 +1,8 @@
import * as plugins from './tapbundle.plugins.js';
import { tapCreator } from './tapbundle.tapcreator.js';
import { TapTools, SkipError } from './tapbundle.classes.taptools.js';
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
import { setProtocolEmitter } from './tapbundle.expect.wrapper.js';
// imported interfaces
import { Deferred } from '@push.rocks/smartpromise';
@ -32,6 +34,7 @@ export class TapTest<T = unknown> {
public testPromise: Promise<TapTest<T>> = this.testDeferred.promise;
private testResultDeferred: Deferred<T> = plugins.smartpromise.defer();
public testResultPromise: Promise<T> = this.testResultDeferred.promise;
private protocolEmitter = new ProtocolEmitter();
/**
* constructor
*/
@ -48,6 +51,13 @@ export class TapTest<T = unknown> {
this.testFunction = optionsArg.testFunction;
}
/**
* Emit an event
*/
private emitEvent(event: ITestEvent) {
console.log(this.protocolEmitter.emitEvent(event));
}
/**
* run the test
*/
@ -55,11 +65,74 @@ export class TapTest<T = unknown> {
this.testKey = testKeyArg;
const testNumber = testKeyArg + 1;
// Emit test:queued event
this.emitEvent({
eventType: 'test:queued',
timestamp: Date.now(),
data: {
testNumber,
description: this.description
}
});
// Handle todo tests
if (this.isTodo) {
const todoText = this.todoReason ? `# TODO ${this.todoReason}` : '# TODO';
console.log(`ok ${testNumber} - ${this.description} ${todoText}`);
const testResult = {
ok: true,
testNumber,
description: this.description,
directive: {
type: 'todo' as const,
reason: this.todoReason
}
};
const lines = this.protocolEmitter.emitTest(testResult);
lines.forEach((line: string) => console.log(line));
this.status = 'success';
// Emit test:completed event for todo test
this.emitEvent({
eventType: 'test:completed',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
duration: 0,
error: undefined
}
});
this.testDeferred.resolve(this);
return;
}
// Handle pre-marked skip tests
if (this.tapTools.isSkipped) {
const testResult = {
ok: true,
testNumber,
description: this.description,
directive: {
type: 'skip' as const,
reason: this.tapTools.skipReason || 'Marked as skip'
}
};
const lines = this.protocolEmitter.emitTest(testResult);
lines.forEach((line: string) => console.log(line));
this.status = 'skipped';
// Emit test:completed event for skipped test
this.emitEvent({
eventType: 'test:completed',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
duration: 0,
error: undefined
}
});
this.testDeferred.resolve(this);
return;
}
@ -71,6 +144,20 @@ export class TapTest<T = unknown> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
this.hrtMeasurement.start();
// Emit test:started event
this.emitEvent({
eventType: 'test:started',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
retry: attempt > 0 ? attempt : undefined
}
});
// Set protocol emitter for enhanced expect
setProtocolEmitter(this.protocolEmitter);
try {
// Set up timeout if specified
let timeoutHandle: any;
@ -97,10 +184,32 @@ export class TapTest<T = unknown> {
}
this.hrtMeasurement.stop();
console.log(
`ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
);
const testResult = {
ok: true,
testNumber,
description: this.description,
metadata: {
time: this.hrtMeasurement.milliSeconds,
tags: this.tags.length > 0 ? this.tags : undefined,
file: this.fileName
}
};
const lines = this.protocolEmitter.emitTest(testResult);
lines.forEach((line: string) => console.log(line));
this.status = 'success';
// Emit test:completed event
this.emitEvent({
eventType: 'test:completed',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
duration: this.hrtMeasurement.milliSeconds,
error: undefined
}
});
this.testDeferred.resolve(this);
this.testResultDeferred.resolve(testReturnValue);
return; // Success, exit retry loop
@ -110,8 +219,31 @@ export class TapTest<T = unknown> {
// Handle skip
if (err instanceof SkipError || err.name === 'SkipError') {
console.log(`ok ${testNumber} - ${this.description} # SKIP ${err.message.replace('Skipped: ', '')}`);
const testResult = {
ok: true,
testNumber,
description: this.description,
directive: {
type: 'skip' as const,
reason: err.message.replace('Skipped: ', '')
}
};
const lines = this.protocolEmitter.emitTest(testResult);
lines.forEach((line: string) => console.log(line));
this.status = 'skipped';
// Emit test:completed event for skipped test
this.emitEvent({
eventType: 'test:completed',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
duration: this.hrtMeasurement.milliSeconds,
error: undefined
}
});
this.testDeferred.resolve(this);
return;
}
@ -120,17 +252,48 @@ export class TapTest<T = unknown> {
// If we have retries left, try again
if (attempt < maxRetries) {
console.log(
`# Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`,
);
console.log(this.protocolEmitter.emitComment(`Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`));
this.tapTools._incrementRetryCount();
continue;
}
// Final failure
console.log(
`not ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
);
const testResult = {
ok: false,
testNumber,
description: this.description,
metadata: {
time: this.hrtMeasurement.milliSeconds,
retry: this.tapTools.retryCount,
maxRetries: maxRetries > 0 ? maxRetries : undefined,
error: {
message: lastError.message || String(lastError),
stack: lastError.stack,
code: lastError.code
},
tags: this.tags.length > 0 ? this.tags : undefined,
file: this.fileName
}
};
const lines = this.protocolEmitter.emitTest(testResult);
lines.forEach((line: string) => console.log(line));
// Emit test:completed event for failed test
this.emitEvent({
eventType: 'test:completed',
timestamp: Date.now(),
data: {
testNumber,
description: this.description,
duration: this.hrtMeasurement.milliSeconds,
error: {
message: lastError.message || String(lastError),
stack: lastError.stack,
type: 'runtime' as const
}
}
});
this.testDeferred.resolve(this);
this.testResultDeferred.resolve(err);

View File

@ -22,6 +22,10 @@ export class TapTools {
public testData: any = {};
private static _sharedContext = new Map<string, any>();
private _snapshotPath: string = '';
// Flags for skip/todo
private _isSkipped = false;
private _skipReason?: string;
constructor(TapTestArg: TapTest<any>) {
this._tapTest = TapTestArg;
@ -45,9 +49,33 @@ export class TapTools {
* skip the rest of the test
*/
public skip(reason?: string): never {
this._isSkipped = true;
this._skipReason = reason;
const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped';
throw new SkipError(skipMessage);
}
/**
* Mark test as skipped without throwing (for pre-marking)
*/
public markAsSkipped(reason?: string): void {
this._isSkipped = true;
this._skipReason = reason;
}
/**
* Check if test is marked as skipped
*/
public get isSkipped(): boolean {
return this._isSkipped;
}
/**
* Get skip reason
*/
public get skipReason(): string | undefined {
return this._skipReason;
}
/**
* conditionally skip the rest of the test

View File

@ -0,0 +1,81 @@
import { expect as smartExpect } from '@push.rocks/smartexpect';
import { generateDiff } from './tapbundle.utilities.diff.js';
import { ProtocolEmitter } from '../dist_ts_tapbundle_protocol/index.js';
import type { IEnhancedError } from '../dist_ts_tapbundle_protocol/index.js';
// Store the protocol emitter for event emission
let protocolEmitter: ProtocolEmitter | null = null;
/**
* Set the protocol emitter for enhanced error reporting
*/
export function setProtocolEmitter(emitter: ProtocolEmitter) {
protocolEmitter = emitter;
}
/**
* Enhanced expect wrapper that captures assertion failures and generates diffs
*/
export function createEnhancedExpect() {
return new Proxy(smartExpect, {
apply(target, thisArg, argumentsList: any[]) {
const expectation = target.apply(thisArg, argumentsList);
// Wrap common assertion methods
const wrappedExpectation = new Proxy(expectation, {
get(target, prop, receiver) {
const originalValue = Reflect.get(target, prop, receiver);
// Wrap assertion methods that compare values
if (typeof prop === 'string' && typeof originalValue === 'function' && ['toEqual', 'toBe', 'toMatch', 'toContain'].includes(prop)) {
return function(expected: any) {
try {
return originalValue.apply(target, arguments);
} catch (error: any) {
// Enhance the error with diff information
const actual = argumentsList[0];
const enhancedError: IEnhancedError = {
message: error.message,
stack: error.stack,
actual,
expected,
type: 'assertion'
};
// Generate diff if applicable
if (prop === 'toEqual' || prop === 'toBe') {
const diff = generateDiff(expected, actual);
if (diff) {
enhancedError.diff = diff;
}
}
// Emit assertion:failed event if protocol emitter is available
if (protocolEmitter) {
const event = {
eventType: 'assertion:failed' as const,
timestamp: Date.now(),
data: {
error: enhancedError
}
};
console.log(protocolEmitter.emitEvent(event));
}
// Re-throw the enhanced error
throw error;
}
};
}
return originalValue;
}
});
return wrappedExpectation;
}
});
}
// Create the enhanced expect function
export const expect = createEnhancedExpect();

View File

@ -0,0 +1,46 @@
export interface ITapSettings {
// Timing
timeout?: number; // Default timeout for all tests (ms)
slowThreshold?: number; // Mark tests as slow if they exceed this (ms)
// Execution Control
bail?: boolean; // Stop on first test failure
retries?: number; // Number of retries for failed tests
retryDelay?: number; // Delay between retries (ms)
// Output Control
suppressConsole?: boolean; // Suppress console output in passing tests
verboseErrors?: boolean; // Show full stack traces
showTestDuration?: boolean; // Show duration for each test
// Parallel Execution
maxConcurrency?: number; // Max parallel tests (for .para files)
isolateTests?: boolean; // Run each test in fresh context
// Lifecycle Hooks
beforeAll?: () => Promise<void> | void;
afterAll?: () => Promise<void> | void;
beforeEach?: (testName: string) => Promise<void> | void;
afterEach?: (testName: string, passed: boolean) => Promise<void> | void;
// Environment
env?: Record<string, string>; // Additional environment variables
// Features
enableSnapshots?: boolean; // Enable snapshot testing
snapshotDirectory?: string; // Custom snapshot directory
updateSnapshots?: boolean; // Update snapshots instead of comparing
}
export interface ISettingsManager {
// Get merged settings for current context
getSettings(): ITapSettings;
// Apply settings at different levels
setGlobalSettings(settings: ITapSettings): void;
setFileSettings(settings: ITapSettings): void;
setTestSettings(testId: string, settings: ITapSettings): void;
// Get settings for specific test
getTestSettings(testId: string): ITapSettings;
}

View File

@ -1,226 +0,0 @@
/**
* Internal protocol constants and utilities for improved TAP communication
* between tapbundle and tstest
*/
export const PROTOCOL = {
VERSION: '2.0',
MARKERS: {
START: '⟦TSTEST:',
END: '⟧',
BLOCK_END: '⟦/TSTEST:',
},
TYPES: {
META: 'META',
ERROR: 'ERROR',
SKIP: 'SKIP',
TODO: 'TODO',
SNAPSHOT: 'SNAPSHOT',
PROTOCOL: 'PROTOCOL',
}
} as const;
export interface TestMetadata {
// 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;
};
// 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 class ProtocolEncoder {
/**
* Encode metadata for inline inclusion
*/
static encodeInline(type: string, data: any): string {
if (typeof data === 'string') {
return `${PROTOCOL.MARKERS.START}${type}:${data}${PROTOCOL.MARKERS.END}`;
}
return `${PROTOCOL.MARKERS.START}${type}:${JSON.stringify(data)}${PROTOCOL.MARKERS.END}`;
}
/**
* Encode block data for multi-line content
*/
static encodeBlock(type: string, data: any): string[] {
const lines: string[] = [];
lines.push(`${PROTOCOL.MARKERS.START}${type}${PROTOCOL.MARKERS.END}`);
if (typeof data === 'string') {
lines.push(data);
} else {
lines.push(JSON.stringify(data, null, 2));
}
lines.push(`${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`);
return lines;
}
/**
* Create a TAP line with metadata
*/
static createTestLine(
status: 'ok' | 'not ok',
number: number,
description: string,
metadata?: TestMetadata
): string {
let line = `${status} ${number} - ${description}`;
if (metadata) {
// For skip/todo, use inline format for compatibility
if (metadata.skip) {
line += ` ${this.encodeInline(PROTOCOL.TYPES.SKIP, metadata.skip)}`;
} else if (metadata.todo) {
line += ` ${this.encodeInline(PROTOCOL.TYPES.TODO, metadata.todo)}`;
} else {
// For other metadata, append inline
const metaCopy = { ...metadata };
delete metaCopy.error; // Error details go in separate block
if (Object.keys(metaCopy).length > 0) {
line += ` ${this.encodeInline(PROTOCOL.TYPES.META, metaCopy)}`;
}
}
}
return line;
}
}
export class ProtocolDecoder {
/**
* Extract all protocol markers from a line
*/
static extractMarkers(line: string): Array<{type: string, data: any, start: number, end: number}> {
const markers: Array<{type: string, data: any, start: number, end: number}> = [];
let searchFrom = 0;
while (true) {
const start = line.indexOf(PROTOCOL.MARKERS.START, searchFrom);
if (start === -1) break;
const end = line.indexOf(PROTOCOL.MARKERS.END, start);
if (end === -1) break;
const content = line.substring(start + PROTOCOL.MARKERS.START.length, end);
const colonIndex = content.indexOf(':');
if (colonIndex !== -1) {
const type = content.substring(0, colonIndex);
const dataStr = content.substring(colonIndex + 1);
let data: any;
try {
// Try to parse as JSON first
data = JSON.parse(dataStr);
} catch {
// If not JSON, treat as string
data = dataStr;
}
markers.push({
type,
data,
start,
end: end + PROTOCOL.MARKERS.END.length
});
}
searchFrom = end + 1;
}
return markers;
}
/**
* Remove protocol markers from a line
*/
static cleanLine(line: string): string {
const markers = this.extractMarkers(line);
// Remove markers from end to start to preserve indices
let cleanedLine = line;
for (let i = markers.length - 1; i >= 0; i--) {
const marker = markers[i];
cleanedLine = cleanedLine.substring(0, marker.start) +
cleanedLine.substring(marker.end);
}
return cleanedLine.trim();
}
/**
* Parse a test line and extract metadata
*/
static parseTestLine(line: string): {
cleaned: string;
metadata: TestMetadata;
} {
const markers = this.extractMarkers(line);
const metadata: TestMetadata = {};
for (const marker of markers) {
switch (marker.type) {
case PROTOCOL.TYPES.META:
Object.assign(metadata, marker.data);
break;
case PROTOCOL.TYPES.SKIP:
metadata.skip = marker.data;
break;
case PROTOCOL.TYPES.TODO:
metadata.todo = marker.data;
break;
}
}
return {
cleaned: this.cleanLine(line),
metadata
};
}
/**
* Check if a line starts a protocol block
*/
static isBlockStart(line: string): {isBlock: boolean, type?: string} {
const trimmed = line.trim();
if (trimmed.startsWith(PROTOCOL.MARKERS.START) && trimmed.endsWith(PROTOCOL.MARKERS.END)) {
const content = trimmed.slice(PROTOCOL.MARKERS.START.length, -PROTOCOL.MARKERS.END.length);
if (!content.includes(':')) {
return { isBlock: true, type: content };
}
}
return { isBlock: false };
}
/**
* Check if a line ends a protocol block
*/
static isBlockEnd(line: string, type: string): boolean {
return line.trim() === `${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`;
}
}

View File

@ -0,0 +1,188 @@
import type { IDiffResult, IDiffChange } from '../dist_ts_tapbundle_protocol/index.js';
/**
* Generate a diff between two values
*/
export function generateDiff(expected: any, actual: any, context: number = 3): IDiffResult | null {
// Handle same values
if (expected === actual) {
return null;
}
// Determine diff type based on values
if (typeof expected === 'string' && typeof actual === 'string') {
return generateStringDiff(expected, actual, context);
} else if (Array.isArray(expected) && Array.isArray(actual)) {
return generateArrayDiff(expected, actual);
} else if (expected && actual && typeof expected === 'object' && typeof actual === 'object') {
return generateObjectDiff(expected, actual);
} else {
return generatePrimitiveDiff(expected, actual);
}
}
/**
* Generate diff for primitive values
*/
function generatePrimitiveDiff(expected: any, actual: any): IDiffResult {
return {
type: 'primitive',
changes: [{
type: 'modify',
oldValue: expected,
newValue: actual
}]
};
}
/**
* Generate diff for strings (line-by-line)
*/
function generateStringDiff(expected: string, actual: string, context: number): IDiffResult {
const expectedLines = expected.split('\n');
const actualLines = actual.split('\n');
const changes: IDiffChange[] = [];
// Simple line-by-line diff
const maxLines = Math.max(expectedLines.length, actualLines.length);
for (let i = 0; i < maxLines; i++) {
const expectedLine = expectedLines[i];
const actualLine = actualLines[i];
if (expectedLine === undefined) {
changes.push({
type: 'add',
line: i,
content: actualLine
});
} else if (actualLine === undefined) {
changes.push({
type: 'remove',
line: i,
content: expectedLine
});
} else if (expectedLine !== actualLine) {
changes.push({
type: 'remove',
line: i,
content: expectedLine
});
changes.push({
type: 'add',
line: i,
content: actualLine
});
}
}
return {
type: 'string',
changes,
context
};
}
/**
* Generate diff for arrays
*/
function generateArrayDiff(expected: any[], actual: any[]): IDiffResult {
const changes: IDiffChange[] = [];
const maxLength = Math.max(expected.length, actual.length);
for (let i = 0; i < maxLength; i++) {
const expectedItem = expected[i];
const actualItem = actual[i];
if (i >= expected.length) {
changes.push({
type: 'add',
path: [String(i)],
newValue: actualItem
});
} else if (i >= actual.length) {
changes.push({
type: 'remove',
path: [String(i)],
oldValue: expectedItem
});
} else if (!deepEqual(expectedItem, actualItem)) {
changes.push({
type: 'modify',
path: [String(i)],
oldValue: expectedItem,
newValue: actualItem
});
}
}
return {
type: 'array',
changes
};
}
/**
* Generate diff for objects
*/
function generateObjectDiff(expected: any, actual: any): IDiffResult {
const changes: IDiffChange[] = [];
const allKeys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
for (const key of allKeys) {
const expectedValue = expected[key];
const actualValue = actual[key];
if (!(key in expected)) {
changes.push({
type: 'add',
path: [key],
newValue: actualValue
});
} else if (!(key in actual)) {
changes.push({
type: 'remove',
path: [key],
oldValue: expectedValue
});
} else if (!deepEqual(expectedValue, actualValue)) {
changes.push({
type: 'modify',
path: [key],
oldValue: expectedValue,
newValue: actualValue
});
}
}
return {
type: 'object',
changes
};
}
/**
* Deep equality check
*/
function deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (typeof a === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, index) => deepEqual(item, b[index]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => deepEqual(a[key], b[key]));
}
return false;
}