feat(core): Implement Protocol V2 with enhanced settings and lifecycle hooks
This commit is contained in:
		
							
								
								
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,5 +1,15 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-05-26 - 2.1.0 - feat(core) | ||||||
|  | Implement Protocol V2 with enhanced settings and lifecycle hooks | ||||||
|  |  | ||||||
|  | - Migrated to Protocol V2 using Unicode markers and structured metadata with new ts_tapbundle_protocol module | ||||||
|  | - Refactored TAP parser/emitter to support improved protocol parsing and error reporting | ||||||
|  | - Integrated global settings via tap.settings() and lifecycle hooks (beforeAll/afterAll, beforeEach/afterEach) | ||||||
|  | - Enhanced expect wrapper with diff generation for clearer assertion failures | ||||||
|  | - Updated test loader to automatically run 00init.ts for proper test configuration | ||||||
|  | - Revised documentation (readme.hints.md, readme.plan.md) to reflect current implementation status and remaining work | ||||||
|  |  | ||||||
| ## 2025-05-25 - 2.0.0 - BREAKING CHANGE(protocol) | ## 2025-05-25 - 2.0.0 - BREAKING CHANGE(protocol) | ||||||
| Introduce protocol v2 implementation and update build configuration with revised build order, new tspublish files, and enhanced documentation | Introduce protocol v2 implementation and update build configuration with revised build order, new tspublish files, and enhanced documentation | ||||||
|  |  | ||||||
|   | |||||||
| @@ -126,3 +126,93 @@ The protocol v2 implementation is contained in a separate `ts_tapbundle_protocol | |||||||
| This architectural decision ensures the protocol can be used in any JavaScript environment without modification and maintains proper build dependencies. | This architectural decision ensures the protocol can be used in any JavaScript environment without modification and maintains proper build dependencies. | ||||||
|  |  | ||||||
| See `readme.protocol.md` for the full specification and `ts_tapbundle_protocol/` for the implementation. | See `readme.protocol.md` for the full specification and `ts_tapbundle_protocol/` for the implementation. | ||||||
|  |  | ||||||
|  | ## Protocol V2 Implementation Status | ||||||
|  |  | ||||||
|  | The Protocol V2 has been implemented to fix issues with TAP protocol parsing when test descriptions contain special characters like `#`, `###SNAPSHOT###`, or protocol markers like `⟦TSTEST:ERROR⟧`. | ||||||
|  |  | ||||||
|  | ### Implementation Details: | ||||||
|  |  | ||||||
|  | 1. **Protocol Components**: | ||||||
|  |    - `ProtocolEmitter` - Generates protocol v2 messages (used by tapbundle) | ||||||
|  |    - `ProtocolParser` - Parses protocol v2 messages (used by tstest) | ||||||
|  |    - Uses Unicode markers `⟦TSTEST:` and `⟧` to avoid conflicts with test content | ||||||
|  |  | ||||||
|  | 2. **Current Status**: | ||||||
|  |    - ✅ Basic protocol emission and parsing works | ||||||
|  |    - ✅ Handles test descriptions with special characters correctly | ||||||
|  |    - ✅ Supports metadata for timing, tags, errors | ||||||
|  |    - ⚠️ Protocol messages sometimes appear in console output (parsing not catching all cases) | ||||||
|  |  | ||||||
|  | 3. **Key Findings**: | ||||||
|  |    - `tap.skip.test()` doesn't create actual test objects, just logs and increments counter | ||||||
|  |    - `tap.todo()` method is not implemented (no `addTodo` method in Tap class) | ||||||
|  |    - Protocol parser's `isBlockStart` was fixed to only match exact block markers, not partial matches in test descriptions | ||||||
|  |  | ||||||
|  | 4. **Import Paths**: | ||||||
|  |    - tstest imports from: `import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js';` | ||||||
|  |    - tapbundle imports from: `import { ProtocolEmitter } from '../dist_ts_tapbundle_protocol/index.js';` | ||||||
|  |  | ||||||
|  | ## Test Configuration System (Phase 2) | ||||||
|  |  | ||||||
|  | The Test Configuration System has been implemented to provide global settings and lifecycle hooks for tests. | ||||||
|  |  | ||||||
|  | ### Key Features: | ||||||
|  |  | ||||||
|  | 1. **00init.ts Discovery**: | ||||||
|  |    - Automatically detects `00init.ts` files in the same directory as test files | ||||||
|  |    - Creates a temporary loader file that imports both `00init.ts` and the test file | ||||||
|  |    - Loader files are cleaned up automatically after test execution | ||||||
|  |  | ||||||
|  | 2. **Settings Inheritance**: | ||||||
|  |    - Global settings from `00init.ts` → File-level settings → Test-level settings | ||||||
|  |    - Settings include: timeout, retries, retryDelay, bail, concurrency | ||||||
|  |    - Lifecycle hooks: beforeAll, afterAll, beforeEach, afterEach | ||||||
|  |  | ||||||
|  | 3. **Implementation Details**: | ||||||
|  |    - `SettingsManager` class handles settings inheritance and merging | ||||||
|  |    - `tap.settings()` API allows configuration at any level | ||||||
|  |    - Lifecycle hooks are integrated into test execution flow | ||||||
|  |  | ||||||
|  | ### Important Development Notes: | ||||||
|  |  | ||||||
|  | 1. **Local Development**: When developing tstest itself, use `node cli.js` instead of globally installed `tstest` to test changes | ||||||
|  |  | ||||||
|  | 2. **Console Output Buffering**: Console output from tests is buffered and only displayed for failing tests. TAP-compliant comments (lines starting with `#`) are always shown. | ||||||
|  |  | ||||||
|  | 3. **TypeScript Warnings**: Fixed async/await warnings in `movePreviousLogFiles()` by using sync versions of file operations | ||||||
|  |  | ||||||
|  | ## Enhanced Communication Features (Phase 3) | ||||||
|  |  | ||||||
|  | The Enhanced Communication system has been implemented to provide rich, real-time feedback during test execution. | ||||||
|  |  | ||||||
|  | ### Key Features: | ||||||
|  |  | ||||||
|  | 1. **Event-Based Test Lifecycle Reporting**: | ||||||
|  |    - `test:queued` - Test is ready to run | ||||||
|  |    - `test:started` - Test execution begins | ||||||
|  |    - `test:completed` - Test finishes (with pass/fail status) | ||||||
|  |    - `suite:started` - Test suite/describe block begins | ||||||
|  |    - `suite:completed` - Test suite/describe block ends | ||||||
|  |    - `hook:started` - Lifecycle hook (beforeEach/afterEach) begins | ||||||
|  |    - `hook:completed` - Lifecycle hook finishes | ||||||
|  |    - `assertion:failed` - Assertion failure with detailed information | ||||||
|  |  | ||||||
|  | 2. **Visual Diff Output for Assertion Failures**: | ||||||
|  |    - **String Diffs**: Character-by-character comparison with colored output | ||||||
|  |    - **Object/Array Diffs**: Deep property comparison showing added/removed/changed properties | ||||||
|  |    - **Primitive Diffs**: Clear display of expected vs actual values | ||||||
|  |    - **Colorized Output**: Green for expected, red for actual, yellow for differences | ||||||
|  |    - **Smart Formatting**: Multi-line strings and complex objects are formatted for readability | ||||||
|  |  | ||||||
|  | 3. **Real-Time Test Progress API**: | ||||||
|  |    - Tests emit progress events as they execute | ||||||
|  |    - tstest parser processes events and updates display in real-time | ||||||
|  |    - Structured event format carries rich metadata (timing, errors, diffs) | ||||||
|  |    - Seamless integration with existing TAP protocol via Protocol V2 | ||||||
|  |  | ||||||
|  | ### Implementation Details: | ||||||
|  | - Events are transmitted via Protocol V2's `EVENT` block type | ||||||
|  | - Event data is JSON-encoded within protocol markers | ||||||
|  | - Parser handles events asynchronously for real-time updates | ||||||
|  | - Visual diffs are generated using custom diff algorithms for each data type | ||||||
							
								
								
									
										170
									
								
								readme.plan.md
									
									
									
									
									
								
							
							
						
						
									
										170
									
								
								readme.plan.md
									
									
									
									
									
								
							| @@ -2,33 +2,33 @@ | |||||||
|  |  | ||||||
| !! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !! | !! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !! | ||||||
|  |  | ||||||
| ## Improved Internal Protocol (NEW - Critical) | ## Improved Internal Protocol (NEW - Critical) ✅ COMPLETED | ||||||
|  |  | ||||||
| ### Current Issues | ### Current Issues ✅ RESOLVED | ||||||
| - TAP protocol uses `#` for metadata which conflicts with test descriptions containing `#` | - ✅ TAP protocol uses `#` for metadata which conflicts with test descriptions containing `#` | ||||||
| - Fragile regex parsing that breaks with special characters | - ✅ Fragile regex parsing that breaks with special characters | ||||||
| - Limited extensibility for new metadata types | - ✅ Limited extensibility for new metadata types | ||||||
|  |  | ||||||
| ### Proposed Solution: Protocol V2 | ### Proposed Solution: Protocol V2 ✅ IMPLEMENTED | ||||||
| - Use Unicode delimiters `⟦TSTEST:META:{}⟧` that won't appear in test names | - ✅ Use Unicode delimiters `⟦TSTEST:META:{}⟧` that won't appear in test names | ||||||
| - Structured JSON metadata format | - ✅ Structured JSON metadata format | ||||||
| - Separate protocol blocks for complex data (errors, snapshots) | - ✅ Separate protocol blocks for complex data (errors, snapshots) | ||||||
| - Complete replacement of v1 (no backwards compatibility needed) | - ✅ Complete replacement of v1 (no backwards compatibility needed) | ||||||
|  |  | ||||||
| ### Implementation | ### Implementation ✅ COMPLETED | ||||||
| - Phase 1: Create protocol v2 implementation in ts_tapbundle_protocol | - ✅ Phase 1: Create protocol v2 implementation in ts_tapbundle_protocol | ||||||
| - Phase 2: Replace all v1 code in both tstest and tapbundle with v2 | - ✅ Phase 2: Replace all v1 code in both tstest and tapbundle with v2 | ||||||
| - Phase 3: Delete all v1 parsing and generation code | - ✅ Phase 3: Delete all v1 parsing and generation code | ||||||
|  |  | ||||||
| #### ts_tapbundle_protocol Directory | #### ts_tapbundle_protocol Directory | ||||||
| The protocol v2 implementation will be contained in the `ts_tapbundle_protocol` directory as isomorphic TypeScript code: | The protocol v2 implementation will be contained in the `ts_tapbundle_protocol` directory as isomorphic TypeScript code: | ||||||
| - **Isomorphic Design**: All code must work in both browser and Node.js environments | - **Isomorphic Design**: All code must work in both browser and Node.js environments | ||||||
| - **No Node.js Imports**: No Node.js-specific modules allowed (no fs, path, child_process, etc.) | - **No Node.js Imports**: No Node.js-specific modules allowed (no fs, path, child_process, etc.) | ||||||
| - **Protocol Classes**: Contains classes implementing all sides of the protocol: | - **Protocol Classes**: Contains classes implementing all sides of the protocol: | ||||||
|   - `ProtocolEmitter`: For generating protocol v2 messages (used by tapbundle) |   - ✅ `ProtocolEmitter`: For generating protocol v2 messages (used by tapbundle) | ||||||
|   - `ProtocolParser`: For parsing protocol v2 messages (used by tstest) |   - ✅ `ProtocolParser`: For parsing protocol v2 messages (used by tstest) | ||||||
|   - `ProtocolMessage`: Base classes for different message types |   - ✅ `ProtocolMessage`: Base classes for different message types | ||||||
|   - `ProtocolTypes`: TypeScript interfaces and types for protocol structures |   - ✅ `ProtocolTypes`: TypeScript interfaces and types for protocol structures | ||||||
| - **Pure TypeScript**: Only browser-compatible APIs and pure TypeScript/JavaScript code | - **Pure TypeScript**: Only browser-compatible APIs and pure TypeScript/JavaScript code | ||||||
| - **Build Integration**:  | - **Build Integration**:  | ||||||
|   - Compiled by `pnpm build` (via tsbuild) to `dist_ts_tapbundle_protocol/` |   - Compiled by `pnpm build` (via tsbuild) to `dist_ts_tapbundle_protocol/` | ||||||
| @@ -92,19 +92,19 @@ interface TapSettings { | |||||||
| 3. **Application**: Apply settings to test execution | 3. **Application**: Apply settings to test execution | ||||||
| 4. **Advanced**: Parallel execution and snapshot configuration | 4. **Advanced**: Parallel execution and snapshot configuration | ||||||
|  |  | ||||||
| ## 1. Enhanced Communication Between tapbundle and tstest | ## 1. Enhanced Communication Between tapbundle and tstest ✅ COMPLETED | ||||||
|  |  | ||||||
| ### 1.1 Real-time Test Progress API | ### 1.1 Real-time Test Progress API ✅ COMPLETED | ||||||
| - Create a bidirectional communication channel between tapbundle and tstest | - ✅ Create a bidirectional communication channel between tapbundle and tstest | ||||||
| - Emit events for test lifecycle stages (start, progress, completion) | - ✅ Emit events for test lifecycle stages (start, progress, completion) | ||||||
| - Allow tstest to subscribe to tapbundle events for better progress reporting | - ✅ Allow tstest to subscribe to tapbundle events for better progress reporting | ||||||
| - Implement a standardized message format for test metadata | - ✅ Implement a standardized message format for test metadata | ||||||
|  |  | ||||||
| ### 1.2 Rich Error Reporting | ### 1.2 Rich Error Reporting ✅ COMPLETED | ||||||
| - Pass structured error objects from tapbundle to tstest | - ✅ Pass structured error objects from tapbundle to tstest | ||||||
| - Include stack traces, code snippets, and contextual information | - ✅ Include stack traces, code snippets, and contextual information | ||||||
| - Support for error categorization (assertion failures, timeouts, uncaught exceptions) | - ✅ Support for error categorization (assertion failures, timeouts, uncaught exceptions) | ||||||
| - Visual diff output for failed assertions | - ✅ Visual diff output for failed assertions | ||||||
|  |  | ||||||
| ## 2. Enhanced toolsArg Functionality | ## 2. Enhanced toolsArg Functionality | ||||||
|  |  | ||||||
| @@ -155,7 +155,7 @@ tap.test('performance test', async (toolsArg) => { | |||||||
| - Fast feedback loop for development | - Fast feedback loop for development | ||||||
| - Integration with IDE/editor plugins | - Integration with IDE/editor plugins | ||||||
|  |  | ||||||
| ### 5.3 Advanced Test Filtering (Partial) | ### 5.3 Advanced Test Filtering (Partial) ⚠️ | ||||||
| ```typescript | ```typescript | ||||||
| // Exclude tests by pattern (not yet implemented) | // Exclude tests by pattern (not yet implemented) | ||||||
| tstest --exclude "**/slow/**" | tstest --exclude "**/slow/**" | ||||||
| @@ -197,38 +197,38 @@ tstest --changed | |||||||
|  |  | ||||||
| ## Implementation Phases | ## Implementation Phases | ||||||
|  |  | ||||||
| ### Phase 1: Improved Internal Protocol (Priority: Critical) (NEW) | ### Phase 1: Improved Internal Protocol (Priority: Critical) ✅ COMPLETED | ||||||
| 1. Create ts_tapbundle_protocol directory with isomorphic protocol v2 implementation | 1. ✅ Create ts_tapbundle_protocol directory with isomorphic protocol v2 implementation | ||||||
|    - Implement ProtocolEmitter class for message generation |    - ✅ Implement ProtocolEmitter class for message generation | ||||||
|    - Implement ProtocolParser class for message parsing |    - ✅ Implement ProtocolParser class for message parsing | ||||||
|    - Define ProtocolMessage types and interfaces |    - ✅ Define ProtocolMessage types and interfaces | ||||||
|    - Ensure all code is browser and Node.js compatible |    - ✅ Ensure all code is browser and Node.js compatible | ||||||
|    - Add tspublish.json to configure build order |    - ✅ Add tspublish.json to configure build order | ||||||
| 2. Update build configuration to compile ts_tapbundle_protocol first | 2. ✅ Update build configuration to compile ts_tapbundle_protocol first | ||||||
| 3. Replace TAP parser in tstest with Protocol V2 parser importing from dist_ts_tapbundle_protocol | 3. ✅ Replace TAP parser in tstest with Protocol V2 parser importing from dist_ts_tapbundle_protocol | ||||||
| 4. Replace TAP generation in tapbundle with Protocol V2 emitter importing from dist_ts_tapbundle_protocol | 4. ✅ Replace TAP generation in tapbundle with Protocol V2 emitter importing from dist_ts_tapbundle_protocol | ||||||
| 5. Delete all v1 TAP parsing code from tstest | 5. ✅ Delete all v1 TAP parsing code from tstest | ||||||
| 6. Delete all v1 TAP generation code from tapbundle | 6. ✅ Delete all v1 TAP generation code from tapbundle | ||||||
| 7. Test with real-world test suites containing special characters | 7. ✅ Test with real-world test suites containing special characters | ||||||
|  |  | ||||||
| ### Phase 2: Test Configuration System (Priority: High) | ### Phase 2: Test Configuration System (Priority: High) ✅ COMPLETED | ||||||
| 1. Implement tap.settings() API with TypeScript interfaces | 1. ✅ Implement tap.settings() API with TypeScript interfaces | ||||||
| 2. Add 00init.ts discovery and loading mechanism | 2. ✅ Add 00init.ts discovery and loading mechanism | ||||||
| 3. Implement settings inheritance and merge logic | 3. ✅ Implement settings inheritance and merge logic | ||||||
| 4. Apply settings to test execution (timeouts, retries, etc.) | 4. ✅ Apply settings to test execution (timeouts, retries, etc.) | ||||||
|  |  | ||||||
| ### Phase 3: Enhanced Communication (Priority: High) | ### Phase 3: Enhanced Communication (Priority: High) ✅ COMPLETED | ||||||
| 1. Build on Protocol V2 for richer communication | 1. ✅ Build on Protocol V2 for richer communication | ||||||
| 2. Implement real-time test progress API | 2. ✅ Implement real-time test progress API | ||||||
| 3. Add structured error reporting with diffs and traces | 3. ✅ Add structured error reporting with diffs and traces | ||||||
|  |  | ||||||
| ### Phase 4: Developer Experience (Priority: Medium) | ### Phase 4: Developer Experience (Priority: Medium) ❌ NOT STARTED | ||||||
| 1. Add watch mode | 1. Add watch mode | ||||||
| 2. Implement custom reporters | 2. Implement custom reporters | ||||||
| 3. Complete advanced test filtering options | 3. Complete advanced test filtering options | ||||||
| 4. Add performance benchmarking API | 4. Add performance benchmarking API | ||||||
|  |  | ||||||
| ### Phase 5: Analytics and Performance (Priority: Low) | ### Phase 5: Analytics and Performance (Priority: Low) ❌ NOT STARTED | ||||||
| 1. Build test analytics dashboard | 1. Build test analytics dashboard | ||||||
| 2. Implement coverage integration | 2. Implement coverage integration | ||||||
| 3. Create trend analysis tools | 3. Create trend analysis tools | ||||||
| @@ -253,3 +253,65 @@ tstest --changed | |||||||
| - Extensible plugin architecture | - Extensible plugin architecture | ||||||
| - Standard test result format | - Standard test result format | ||||||
| - Compatible with existing CI/CD tools | - Compatible with existing CI/CD tools | ||||||
|  |  | ||||||
|  | ## Summary of Remaining Work | ||||||
|  |  | ||||||
|  | ### ✅ Completed | ||||||
|  | - **Protocol V2**: Full implementation with Unicode delimiters, structured metadata, and special character handling | ||||||
|  | - **Test Configuration System**: tap.settings() API, 00init.ts discovery, settings inheritance, lifecycle hooks | ||||||
|  | - **Enhanced Communication**: Event-based test lifecycle reporting, visual diff output for assertion failures, real-time test progress API | ||||||
|  | - **Rich Error Reporting**: Stack traces, error metadata, and visual diffs through protocol | ||||||
|  | - **Tags Filtering**: `--tags` option for running specific tagged tests | ||||||
|  |  | ||||||
|  | ### ✅ Existing Features (Not in Plan) | ||||||
|  | - **Timeout Support**: `--timeout` option and per-test timeouts | ||||||
|  | - **Test Retries**: `tap.retry()` for flaky test handling | ||||||
|  | - **Parallel Tests**: `.testParallel()` for concurrent execution | ||||||
|  | - **Snapshot Testing**: Basic implementation with `toMatchSnapshot()` | ||||||
|  | - **Test Lifecycle**: `describe()` blocks with `beforeEach`/`afterEach` | ||||||
|  | - **Skip Tests**: `tap.skip.test()` (though it doesn't create test objects) | ||||||
|  | - **Log Files**: `--logfile` option saves output to `.nogit/testlogs/` | ||||||
|  | - **Test Range**: `--startFrom` and `--stopAt` for partial runs | ||||||
|  |  | ||||||
|  | ### ⚠️ Partially Completed | ||||||
|  | - **Advanced Test Filtering**: Have `--tags` but missing `--exclude`, `--failed`, `--changed` | ||||||
|  |  | ||||||
|  | ### ❌ Not Started | ||||||
|  |  | ||||||
|  | #### High Priority | ||||||
|  |  | ||||||
|  | #### Medium Priority | ||||||
|  | 2. **Developer Experience** | ||||||
|  |    - Watch mode for file changes | ||||||
|  |    - Custom reporters (JSON, JUnit, HTML, Markdown) | ||||||
|  |    - Performance benchmarking API | ||||||
|  |    - Better error messages with suggestions | ||||||
|  |  | ||||||
|  | 3. **Enhanced toolsArg** | ||||||
|  |    - Test data injection | ||||||
|  |    - Context sharing between tests | ||||||
|  |    - Parameterized tests | ||||||
|  |  | ||||||
|  | 4. **Test Organization** | ||||||
|  |    - Hierarchical test suites | ||||||
|  |    - Nested describe blocks | ||||||
|  |    - Suite-level lifecycle hooks | ||||||
|  |  | ||||||
|  | #### Low Priority | ||||||
|  | 5. **Analytics and Performance** | ||||||
|  |    - Test analytics dashboard | ||||||
|  |    - Code coverage integration | ||||||
|  |    - Trend analysis | ||||||
|  |    - Flaky test detection | ||||||
|  |  | ||||||
|  | ### Known Issues to Fix | ||||||
|  | - **tap.todo()**: Method exists but has no implementation | ||||||
|  | - **tap.skip.test()**: Doesn't create test objects, just logs (breaks test count) | ||||||
|  | - **Protocol Output**: Some protocol messages still appear in console output | ||||||
|  | - **Only Tests**: `tap.only.test()` exists but `--only` mode not fully implemented | ||||||
|  |  | ||||||
|  | ### Next Recommended Steps | ||||||
|  | 1. Add Watch Mode - high developer value | ||||||
|  | 2. Implement Custom Reporters - important for CI/CD integration | ||||||
|  | 3. Fix known issues: tap.todo() and tap.skip.test() implementations | ||||||
|  | 4. Implement performance benchmarking API | ||||||
							
								
								
									
										41
									
								
								test/config-test/00init.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								test/config-test/00init.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | import { tap } from '../../ts_tapbundle/index.js'; | ||||||
|  |  | ||||||
|  | // TAP-compliant comment output | ||||||
|  | console.log('# 🚀 00init.ts: LOADED AND EXECUTING'); | ||||||
|  | console.log('# 🚀 00init.ts: Setting up global test configuration'); | ||||||
|  |  | ||||||
|  | // Add a global variable to verify 00init.ts was loaded | ||||||
|  | (global as any).__00INIT_LOADED = true; | ||||||
|  |  | ||||||
|  | // Configure global test settings | ||||||
|  | tap.settings({ | ||||||
|  |   // Set a default timeout of 5 seconds for all tests | ||||||
|  |   timeout: 5000, | ||||||
|  |    | ||||||
|  |   // Enable retries for flaky tests | ||||||
|  |   retries: 2, | ||||||
|  |   retryDelay: 1000, | ||||||
|  |    | ||||||
|  |   // Show test duration | ||||||
|  |   showTestDuration: true, | ||||||
|  |    | ||||||
|  |   // Global lifecycle hooks | ||||||
|  |   beforeAll: async () => { | ||||||
|  |     console.log('Global beforeAll: Initializing test environment'); | ||||||
|  |   }, | ||||||
|  |    | ||||||
|  |   afterAll: async () => { | ||||||
|  |     console.log('Global afterAll: Cleaning up test environment'); | ||||||
|  |   }, | ||||||
|  |    | ||||||
|  |   beforeEach: async (testName: string) => { | ||||||
|  |     console.log(`Global beforeEach: Starting test "${testName}"`); | ||||||
|  |   }, | ||||||
|  |    | ||||||
|  |   afterEach: async (testName: string, passed: boolean) => { | ||||||
|  |     console.log(`Global afterEach: Test "${testName}" ${passed ? 'passed' : 'failed'}`); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | console.log('# 🚀 00init.ts: Configuration COMPLETE'); | ||||||
|  | console.log('# 🚀 00init.ts: tap.settings() called successfully'); | ||||||
							
								
								
									
										44
									
								
								test/config-test/test.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								test/config-test/test.config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | import { tap, expect } from '../../ts_tapbundle/index.js'; | ||||||
|  |  | ||||||
|  | // TAP-compliant comment output | ||||||
|  | console.log('# 🔍 TEST FILE LOADED - test.config.ts'); | ||||||
|  |  | ||||||
|  | // Check if 00init.ts was loaded | ||||||
|  | const initLoaded = (global as any).__00INIT_LOADED; | ||||||
|  | console.log(`# 🔍 00init.ts loaded: ${initLoaded === true}`); | ||||||
|  |  | ||||||
|  | // Test that uses the global timeout setting | ||||||
|  | tap.test('Test with global timeout', async (toolsArg) => { | ||||||
|  |   // This test should complete within the 5 second timeout set in 00init.ts | ||||||
|  |   await toolsArg.delayFor(2000); // 2 seconds | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Test that demonstrates retries | ||||||
|  | tap.test('Test with retries', async () => { | ||||||
|  |   // This test will use the global retry setting (2 retries) | ||||||
|  |   console.log('Running test that might be flaky'); | ||||||
|  |    | ||||||
|  |   // Simulate a flaky test that passes on second try | ||||||
|  |   const randomValue = Math.random(); | ||||||
|  |   console.log(`Random value: ${randomValue}`); | ||||||
|  |    | ||||||
|  |   // Always pass for demonstration | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Test with custom timeout that overrides global | ||||||
|  | tap.timeout(1000).test('Test with custom timeout', async (toolsArg) => { | ||||||
|  |   // This test has a 1 second timeout, overriding the global 5 seconds | ||||||
|  |   await toolsArg.delayFor(500); // 500ms - should pass | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Test to verify lifecycle hooks are working | ||||||
|  | tap.test('Test lifecycle hooks', async () => { | ||||||
|  |   console.log('Inside test: lifecycle hooks should have run'); | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Start the test suite | ||||||
|  | tap.start(); | ||||||
							
								
								
									
										22
									
								
								test/config-test/test.file-settings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								test/config-test/test.file-settings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | import { tap, expect } from '../../ts_tapbundle/index.js'; | ||||||
|  |  | ||||||
|  | // Override global settings for this file | ||||||
|  | tap.settings({ | ||||||
|  |   timeout: 2000, // Override global timeout to 2 seconds | ||||||
|  |   retries: 0,    // Disable retries for this file | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('Test with file-level timeout', async (toolsArg) => { | ||||||
|  |   // This should use the file-level timeout of 2 seconds | ||||||
|  |   console.log('Running with file-level timeout of 2 seconds'); | ||||||
|  |   await toolsArg.delayFor(1000); // 1 second - should pass | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('Test without retries', async () => { | ||||||
|  |   // This test should not retry even if it fails | ||||||
|  |   console.log('This test has no retries (file-level setting)'); | ||||||
|  |   expect(true).toBeTrue(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start(); | ||||||
							
								
								
									
										56
									
								
								test/tapbundle/test.diff-demo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								test/tapbundle/test.diff-demo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | import { tap, expect } from '../../ts_tapbundle/index.js'; | ||||||
|  |  | ||||||
|  | tap.test('should show string diff', async () => { | ||||||
|  |   const expected = `Hello World | ||||||
|  | This is a test | ||||||
|  | of multiline strings`; | ||||||
|  |    | ||||||
|  |   const actual = `Hello World | ||||||
|  | This is a demo | ||||||
|  | of multiline strings`; | ||||||
|  |    | ||||||
|  |   // This will fail and show a diff | ||||||
|  |   expect(actual).toEqual(expected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should show object diff', async () => { | ||||||
|  |   const expected = { | ||||||
|  |     name: 'John', | ||||||
|  |     age: 30, | ||||||
|  |     city: 'New York', | ||||||
|  |     hobbies: ['reading', 'coding'] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const actual = { | ||||||
|  |     name: 'John', | ||||||
|  |     age: 31, | ||||||
|  |     city: 'Boston', | ||||||
|  |     hobbies: ['reading', 'gaming'] | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   // This will fail and show a diff | ||||||
|  |   expect(actual).toEqual(expected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should show array diff', async () => { | ||||||
|  |   const expected = [1, 2, 3, 4, 5]; | ||||||
|  |   const actual = [1, 2, 3, 5, 6]; | ||||||
|  |    | ||||||
|  |   // This will fail and show a diff | ||||||
|  |   expect(actual).toEqual(expected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should show primitive diff', async () => { | ||||||
|  |   const expected = 42; | ||||||
|  |   const actual = 43; | ||||||
|  |    | ||||||
|  |   // This will fail and show a diff | ||||||
|  |   expect(actual).toBe(expected); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should pass without diff', async () => { | ||||||
|  |   expect(true).toBe(true); | ||||||
|  |   expect('hello').toEqual('hello'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start({ throwOnError: false }); | ||||||
							
								
								
									
										23
									
								
								test/tapbundle/test.simple-diff.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								test/tapbundle/test.simple-diff.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import { tap, expect } from '../../ts_tapbundle/index.js'; | ||||||
|  |  | ||||||
|  | tap.test('should show string diff', async () => { | ||||||
|  |   const expected = `line 1 | ||||||
|  | line 2 | ||||||
|  | line 3`; | ||||||
|  |    | ||||||
|  |   const actual = `line 1 | ||||||
|  | line changed | ||||||
|  | line 3`; | ||||||
|  |    | ||||||
|  |   try { | ||||||
|  |     expect(actual).toEqual(expected); | ||||||
|  |   } catch (e) { | ||||||
|  |     // Expected to fail | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.test('should pass', async () => { | ||||||
|  |   expect('hello').toEqual('hello'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | tap.start({ throwOnError: false }); | ||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@git.zone/tstest', |   name: '@git.zone/tstest', | ||||||
|   version: '2.0.0', |   version: '2.1.0', | ||||||
|   description: 'a test utility to run tests that match test/**/*.ts' |   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 { TapTestResult } from './tstest.classes.tap.testresult.js'; | ||||||
| import * as logPrefixes from './tstest.logprefixes.js'; | import * as logPrefixes from './tstest.logprefixes.js'; | ||||||
| import { TsTestLogger } from './tstest.logging.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 { | export class TapParser { | ||||||
|   testStore: TapTestResult[] = []; |   testStore: TapTestResult[] = []; | ||||||
|  |  | ||||||
|   expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/; |   expectedTests: number = 0; | ||||||
|   expectedTests: number; |   receivedTests: number = 0; | ||||||
|   receivedTests: number; |  | ||||||
|  |  | ||||||
|   testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*?)(\s#\s(.*))?$/; |  | ||||||
|   activeTapTestResult: TapTestResult; |   activeTapTestResult: TapTestResult; | ||||||
|   collectingErrorDetails: boolean = false; |  | ||||||
|   currentTestError: string[] = []; |  | ||||||
|  |  | ||||||
|   pretaskRegex = /^::__PRETASK:(.*)$/; |  | ||||||
|    |    | ||||||
|   private logger: TsTestLogger; |   private logger: TsTestLogger; | ||||||
|  |   private protocolParser: ProtocolParser; | ||||||
|  |   private protocolVersion: string | null = null; | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * the constructor for TapParser |    * the constructor for TapParser | ||||||
|    */ |    */ | ||||||
|   constructor(public fileName: string, logger?: TsTestLogger) { |   constructor(public fileName: string, logger?: TsTestLogger) { | ||||||
|     this.logger = logger; |     this.logger = logger; | ||||||
|  |     this.protocolParser = new ProtocolParser(); | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   /** |   /** | ||||||
| @@ -75,138 +74,300 @@ export class TapParser { | |||||||
|       logLineArray.pop(); |       logLineArray.pop(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // lets parse the log information |     // Process each line through the protocol parser | ||||||
|     for (const logLine of logLineArray) { |     for (const logLine of logLineArray) { | ||||||
|       let logLineIsTapProtocol = false; |       const messages = this.protocolParser.parseLine(logLine); | ||||||
|       if (!this.expectedTests && this.expectedTestsRegex.test(logLine)) { |        | ||||||
|         logLineIsTapProtocol = true; |       if (messages.length > 0) { | ||||||
|         const regexResult = this.expectedTestsRegex.exec(logLine); |         // Handle protocol messages | ||||||
|         this.expectedTests = parseInt(regexResult[2]); |         for (const message of messages) { | ||||||
|         if (this.logger) { |           this._handleProtocolMessage(message, logLine); | ||||||
|           this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`); |  | ||||||
|         } |         } | ||||||
|  |       } else { | ||||||
|         // initiating first TapResult |         // Not a protocol message, handle as console output | ||||||
|         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) { |  | ||||||
|         if (this.activeTapTestResult) { |         if (this.activeTapTestResult) { | ||||||
|           this.activeTapTestResult.addLogLine(logLine); |           this.activeTapTestResult.addLogLine(logLine); | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         // Check for snapshot communication |         // Check for snapshot communication (legacy) | ||||||
|         const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/); |         const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/); | ||||||
|         if (snapshotMatch) { |         if (snapshotMatch) { | ||||||
|           const base64Data = snapshotMatch[1]; |           const base64Data = snapshotMatch[1]; | ||||||
|           try { |           try { | ||||||
|             const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString()); |             const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString()); | ||||||
|             this.handleSnapshot(snapshotData); |             this.handleSnapshot(snapshotData); | ||||||
|           } catch (error) { |           } catch (error: any) { | ||||||
|             if (this.logger) { |             if (this.logger) { | ||||||
|               this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`); |               this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`); | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         } else { |         } else if (this.logger) { | ||||||
|           // Check if we're collecting error details |           // This is console output from the test file | ||||||
|           if (this.collectingErrorDetails) { |           this.logger.testConsoleOutput(logLine); | ||||||
|             // 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); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|       if (this.activeTapTestResult && this.activeTapTestResult.testSettled) { |   private _handleProtocolMessage(message: IProtocolMessage, originalLine: string) { | ||||||
|         // Ensure any pending error is shown before settling the test |     switch (message.type) { | ||||||
|         if (this.collectingErrorDetails && this.currentTestError.length > 0) { |       case 'protocol': | ||||||
|           const errorMessage = this.currentTestError.join('\n'); |         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) { |           if (this.logger) { | ||||||
|             this.logger.testErrorDetails(errorMessage); |             this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`); | ||||||
|           } |           } | ||||||
|           this.collectingErrorDetails = false; |         } else { | ||||||
|           this.currentTestError = []; |           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.testStore.push(this.activeTapTestResult); | ||||||
|         this._getNewTapTestResult(); |         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 |    * returns all tests that are not completed | ||||||
|    */ |    */ | ||||||
|   | |||||||
| @@ -161,9 +161,45 @@ export class TsTest { | |||||||
|       process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); |       process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const execResultStreaming = await this.smartshellInstance.execStreamingSilent( |     // Check for 00init.ts file in test directory | ||||||
|       `tsrun ${fileNameArg}${tsrunOptions}` |     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 |     // Handle timeout if specified | ||||||
|     if (this.timeoutSeconds !== null) { |     if (this.timeoutSeconds !== null) { | ||||||
| @@ -382,10 +418,10 @@ export class TsTest { | |||||||
|     try { |     try { | ||||||
|       // Delete 00err and 00diff directories if they exist |       // Delete 00err and 00diff directories if they exist | ||||||
|       if (await plugins.smartfile.fs.isDirectory(errDir)) { |       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)) { |       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) |       // Get all .log files in log directory (not in subdirectories) | ||||||
|   | |||||||
| @@ -1,11 +1,7 @@ | |||||||
| export { tap } from './tapbundle.classes.tap.js'; | export { tap } from './tapbundle.classes.tap.js'; | ||||||
| export { TapWrap } from './tapbundle.classes.tapwrap.js'; | export { TapWrap } from './tapbundle.classes.tapwrap.js'; | ||||||
| export { webhelpers } from './webhelpers.js'; | export { webhelpers } from './webhelpers.js'; | ||||||
|  |  | ||||||
| // Protocol utilities (for future protocol v2) |  | ||||||
| export * from './tapbundle.protocols.js'; |  | ||||||
| export { TapTools } from './tapbundle.classes.taptools.js'; | export { TapTools } from './tapbundle.classes.taptools.js'; | ||||||
|  |  | ||||||
| import { expect } from '@push.rocks/smartexpect'; | // Export enhanced expect with diff generation | ||||||
|  | export { expect, setProtocolEmitter } from './tapbundle.expect.wrapper.js'; | ||||||
| export { expect }; |  | ||||||
|   | |||||||
							
								
								
									
										117
									
								
								ts_tapbundle/tapbundle.classes.settingsmanager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								ts_tapbundle/tapbundle.classes.settingsmanager.ts
									
									
									
									
									
										Normal 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(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -2,6 +2,9 @@ import * as plugins from './tapbundle.plugins.js'; | |||||||
|  |  | ||||||
| import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js'; | import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js'; | ||||||
| import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.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 { | export interface ITestSuite { | ||||||
|   description: string; |   description: string; | ||||||
| @@ -102,6 +105,8 @@ class TestBuilder<T> { | |||||||
| } | } | ||||||
|  |  | ||||||
| export class Tap<T> { | export class Tap<T> { | ||||||
|  |   private protocolEmitter = new ProtocolEmitter(); | ||||||
|  |   private settingsManager = new SettingsManager(); | ||||||
|   private _skipCount = 0; |   private _skipCount = 0; | ||||||
|   private _filterTags: string[] = []; |   private _filterTags: string[] = []; | ||||||
|    |    | ||||||
| @@ -139,12 +144,27 @@ export class Tap<T> { | |||||||
|    */ |    */ | ||||||
|   public skip = { |   public skip = { | ||||||
|     test: (descriptionArg: string, functionArg: ITestFunction<T>) => { |     test: (descriptionArg: string, functionArg: ITestFunction<T>) => { | ||||||
|       console.log(`skipped test: ${descriptionArg}`); |       const skippedTest = this.test(descriptionArg, functionArg, 'skip'); | ||||||
|       this._skipCount++; |       return skippedTest; | ||||||
|     }, |     }, | ||||||
|     testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => { |     testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => { | ||||||
|       console.log(`skipped test: ${descriptionArg}`); |       const skippedTest = new TapTest<T>({ | ||||||
|       this._skipCount++; |         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 = { |   public only = { | ||||||
|     test: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => { |     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 _currentSuite: ITestSuite | null = null; | ||||||
|   private _rootSuites: ITestSuite[] = []; |   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 |    * Normal test function, will run one by one | ||||||
|    * @param testDescription - A description of what the test does |    * @param testDescription - A description of what the test does | ||||||
| @@ -179,14 +272,26 @@ export class Tap<T> { | |||||||
|       parallel: false, |       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 we're in a suite, add test to the suite | ||||||
|     if (this._currentSuite) { |     if (this._currentSuite) { | ||||||
|       this._currentSuite.tests.push(localTest); |       this._currentSuite.tests.push(localTest); | ||||||
|     } else { |     } else { | ||||||
|       // Otherwise add to global test list |       // Otherwise add to global test list | ||||||
|       if (modeArg === 'normal') { |       if (modeArg === 'normal' || modeArg === 'skip') { | ||||||
|         this._tapTests.push(localTest); |         this._tapTests.push(localTest); | ||||||
|       } else if (modeArg === 'only') { |       } else if (modeArg === 'only') { | ||||||
|         this._tapTestsOnly.push(localTest); |         this._tapTestsOnly.push(localTest); | ||||||
| @@ -211,6 +316,15 @@ export class Tap<T> { | |||||||
|       parallel: true, |       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) { |     if (this._currentSuite) { | ||||||
|       this._currentSuite.tests.push(localTest); |       this._currentSuite.tests.push(localTest); | ||||||
|     } else { |     } else { | ||||||
| @@ -336,8 +450,27 @@ export class Tap<T> { | |||||||
|       await preTask.run(); |       await preTask.run(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Count actual tests that will be run |     // Emit protocol header and TAP version | ||||||
|     console.log(`1..${concerningTests.length}`); |     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 |     // Run tests from suites with lifecycle hooks | ||||||
|     let testKey = 0; |     let testKey = 0; | ||||||
| @@ -365,6 +498,33 @@ export class Tap<T> { | |||||||
|     }); |     }); | ||||||
|      |      | ||||||
|     for (const currentTest of nonSuiteTests) { |     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++); |       const testPromise = currentTest.run(testKey++); | ||||||
|       if (currentTest.parallel) { |       if (currentTest.parallel) { | ||||||
|         promiseArray.push(testPromise); |         promiseArray.push(testPromise); | ||||||
| @@ -394,6 +554,16 @@ export class Tap<T> { | |||||||
|       console.log(failReason); |       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 (optionsArg && optionsArg.throwOnError && failReasons.length > 0) { | ||||||
|       if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1); |       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 |    * Run tests in a suite with lifecycle hooks | ||||||
|    */ |    */ | ||||||
| @@ -412,6 +589,14 @@ export class Tap<T> { | |||||||
|     context: { testKey: number } |     context: { testKey: number } | ||||||
|   ) { |   ) { | ||||||
|     for (const suite of suites) { |     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 |       // Run beforeEach from parent suites | ||||||
|       const beforeEachFunctions: ITestFunction<any>[] = []; |       const beforeEachFunctions: ITestFunction<any>[] = []; | ||||||
|       let currentSuite: ITestSuite | null = suite; |       let currentSuite: ITestSuite | null = suite; | ||||||
| @@ -426,27 +611,46 @@ export class Tap<T> { | |||||||
|       for (const test of suite.tests) { |       for (const test of suite.tests) { | ||||||
|         // Create wrapper test function that includes lifecycle hooks |         // Create wrapper test function that includes lifecycle hooks | ||||||
|         const originalFunction = test.testFunction; |         const originalFunction = test.testFunction; | ||||||
|  |         const testName = test.description; | ||||||
|         test.testFunction = async (tapTools) => { |         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) { |           for (const beforeEach of beforeEachFunctions) { | ||||||
|             await beforeEach(tapTools); |             await beforeEach(tapTools); | ||||||
|           } |           } | ||||||
|            |            | ||||||
|           // Run the actual test |           // Run the actual test | ||||||
|           const result = await originalFunction(tapTools); |           let testPassed = true; | ||||||
|            |           let result: any; | ||||||
|           // Run afterEach hooks in reverse order |           try { | ||||||
|           const afterEachFunctions: ITestFunction<any>[] = []; |             result = await originalFunction(tapTools); | ||||||
|           currentSuite = suite; |           } catch (error) { | ||||||
|           while (currentSuite) { |             testPassed = false; | ||||||
|             if (currentSuite.afterEach) { |             throw error; | ||||||
|               afterEachFunctions.push(currentSuite.afterEach); |           } 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; | ||||||
|             } |             } | ||||||
|             currentSuite = currentSuite.parent || null; |  | ||||||
|           } |  | ||||||
|              |              | ||||||
|           for (const afterEach of afterEachFunctions) { |             for (const afterEach of afterEachFunctions) { | ||||||
|             await afterEach(tapTools); |               await afterEach(tapTools); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // Run global afterEach if configured | ||||||
|  |             if (settings.afterEach) { | ||||||
|  |               await settings.afterEach(testName, testPassed); | ||||||
|  |             } | ||||||
|           } |           } | ||||||
|            |            | ||||||
|           return result; |           return result; | ||||||
| @@ -462,6 +666,15 @@ export class Tap<T> { | |||||||
|        |        | ||||||
|       // Recursively run child suites |       // Recursively run child suites | ||||||
|       await this._runSuite(suite, suite.children, promiseArray, context); |       await this._runSuite(suite, suite.children, promiseArray, context); | ||||||
|  |        | ||||||
|  |       // Emit suite:completed event | ||||||
|  |       this.emitEvent({ | ||||||
|  |         eventType: 'suite:completed', | ||||||
|  |         timestamp: Date.now(), | ||||||
|  |         data: { | ||||||
|  |           suiteName: suite.description | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import * as plugins from './tapbundle.plugins.js'; | import * as plugins from './tapbundle.plugins.js'; | ||||||
| import { tapCreator } from './tapbundle.tapcreator.js'; | import { tapCreator } from './tapbundle.tapcreator.js'; | ||||||
| import { TapTools, SkipError } from './tapbundle.classes.taptools.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 | // imported interfaces | ||||||
| import { Deferred } from '@push.rocks/smartpromise'; | import { Deferred } from '@push.rocks/smartpromise'; | ||||||
| @@ -32,6 +34,7 @@ export class TapTest<T = unknown> { | |||||||
|   public testPromise: Promise<TapTest<T>> = this.testDeferred.promise; |   public testPromise: Promise<TapTest<T>> = this.testDeferred.promise; | ||||||
|   private testResultDeferred: Deferred<T> = plugins.smartpromise.defer(); |   private testResultDeferred: Deferred<T> = plugins.smartpromise.defer(); | ||||||
|   public testResultPromise: Promise<T> = this.testResultDeferred.promise; |   public testResultPromise: Promise<T> = this.testResultDeferred.promise; | ||||||
|  |   private protocolEmitter = new ProtocolEmitter(); | ||||||
|   /** |   /** | ||||||
|    * constructor |    * constructor | ||||||
|    */ |    */ | ||||||
| @@ -48,6 +51,13 @@ export class TapTest<T = unknown> { | |||||||
|     this.testFunction = optionsArg.testFunction; |     this.testFunction = optionsArg.testFunction; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit an event | ||||||
|  |    */ | ||||||
|  |   private emitEvent(event: ITestEvent) { | ||||||
|  |     console.log(this.protocolEmitter.emitEvent(event)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * run the test |    * run the test | ||||||
|    */ |    */ | ||||||
| @@ -55,11 +65,74 @@ export class TapTest<T = unknown> { | |||||||
|     this.testKey = testKeyArg; |     this.testKey = testKeyArg; | ||||||
|     const testNumber = testKeyArg + 1; |     const testNumber = testKeyArg + 1; | ||||||
|      |      | ||||||
|  |     // Emit test:queued event | ||||||
|  |     this.emitEvent({ | ||||||
|  |       eventType: 'test:queued', | ||||||
|  |       timestamp: Date.now(), | ||||||
|  |       data: { | ||||||
|  |         testNumber, | ||||||
|  |         description: this.description | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|     // Handle todo tests |     // Handle todo tests | ||||||
|     if (this.isTodo) { |     if (this.isTodo) { | ||||||
|       const todoText = this.todoReason ? `# TODO ${this.todoReason}` : '# TODO'; |       const testResult = { | ||||||
|       console.log(`ok ${testNumber} - ${this.description} ${todoText}`); |         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'; |       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); |       this.testDeferred.resolve(this); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -71,6 +144,20 @@ export class TapTest<T = unknown> { | |||||||
|     for (let attempt = 0; attempt <= maxRetries; attempt++) { |     for (let attempt = 0; attempt <= maxRetries; attempt++) { | ||||||
|       this.hrtMeasurement.start(); |       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 { |       try { | ||||||
|         // Set up timeout if specified |         // Set up timeout if specified | ||||||
|         let timeoutHandle: any; |         let timeoutHandle: any; | ||||||
| @@ -97,10 +184,32 @@ export class TapTest<T = unknown> { | |||||||
|         } |         } | ||||||
|          |          | ||||||
|         this.hrtMeasurement.stop(); |         this.hrtMeasurement.stop(); | ||||||
|         console.log( |         const testResult = { | ||||||
|           `ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`, |           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'; |         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.testDeferred.resolve(this); | ||||||
|         this.testResultDeferred.resolve(testReturnValue); |         this.testResultDeferred.resolve(testReturnValue); | ||||||
|         return; // Success, exit retry loop |         return; // Success, exit retry loop | ||||||
| @@ -110,8 +219,31 @@ export class TapTest<T = unknown> { | |||||||
|          |          | ||||||
|         // Handle skip |         // Handle skip | ||||||
|         if (err instanceof SkipError || err.name === 'SkipError') { |         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'; |           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); |           this.testDeferred.resolve(this); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
| @@ -120,17 +252,48 @@ export class TapTest<T = unknown> { | |||||||
|          |          | ||||||
|         // If we have retries left, try again |         // If we have retries left, try again | ||||||
|         if (attempt < maxRetries) { |         if (attempt < maxRetries) { | ||||||
|           console.log( |           console.log(this.protocolEmitter.emitComment(`Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`)); | ||||||
|             `# Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`, |  | ||||||
|           ); |  | ||||||
|           this.tapTools._incrementRetryCount(); |           this.tapTools._incrementRetryCount(); | ||||||
|           continue; |           continue; | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         // Final failure |         // Final failure | ||||||
|         console.log( |         const testResult = { | ||||||
|           `not ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`, |           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.testDeferred.resolve(this); | ||||||
|         this.testResultDeferred.resolve(err); |         this.testResultDeferred.resolve(err); | ||||||
|          |          | ||||||
|   | |||||||
| @@ -23,6 +23,10 @@ export class TapTools { | |||||||
|   private static _sharedContext = new Map<string, any>(); |   private static _sharedContext = new Map<string, any>(); | ||||||
|   private _snapshotPath: string = ''; |   private _snapshotPath: string = ''; | ||||||
|    |    | ||||||
|  |   // Flags for skip/todo | ||||||
|  |   private _isSkipped = false; | ||||||
|  |   private _skipReason?: string; | ||||||
|  |  | ||||||
|   constructor(TapTestArg: TapTest<any>) { |   constructor(TapTestArg: TapTest<any>) { | ||||||
|     this._tapTest = TapTestArg; |     this._tapTest = TapTestArg; | ||||||
|     // Generate snapshot path based on test file and test name |     // Generate snapshot path based on test file and test name | ||||||
| @@ -45,10 +49,34 @@ export class TapTools { | |||||||
|    * skip the rest of the test |    * skip the rest of the test | ||||||
|    */ |    */ | ||||||
|   public skip(reason?: string): never { |   public skip(reason?: string): never { | ||||||
|  |     this._isSkipped = true; | ||||||
|  |     this._skipReason = reason; | ||||||
|     const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped'; |     const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped'; | ||||||
|     throw new SkipError(skipMessage); |     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 |    * conditionally skip the rest of the test | ||||||
|    */ |    */ | ||||||
|   | |||||||
							
								
								
									
										81
									
								
								ts_tapbundle/tapbundle.expect.wrapper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								ts_tapbundle/tapbundle.expect.wrapper.ts
									
									
									
									
									
										Normal 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(); | ||||||
							
								
								
									
										46
									
								
								ts_tapbundle/tapbundle.interfaces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								ts_tapbundle/tapbundle.interfaces.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
| @@ -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}`; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										188
									
								
								ts_tapbundle/tapbundle.utilities.diff.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								ts_tapbundle/tapbundle.utilities.diff.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								ts_tapbundle_protocol/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								ts_tapbundle_protocol/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | // Protocol V2 - Isomorphic implementation for improved TAP protocol | ||||||
|  | // This module is designed to work in both browser and Node.js environments | ||||||
|  |  | ||||||
|  | export * from './protocol.types.js'; | ||||||
|  | export * from './protocol.emitter.js'; | ||||||
|  | export * from './protocol.parser.js'; | ||||||
|  |  | ||||||
|  | // Re-export main classes for convenience | ||||||
|  | export { ProtocolEmitter } from './protocol.emitter.js'; | ||||||
|  | export { ProtocolParser } from './protocol.parser.js'; | ||||||
|  |  | ||||||
|  | // Re-export constants | ||||||
|  | export { PROTOCOL_MARKERS, PROTOCOL_VERSION } from './protocol.types.js'; | ||||||
							
								
								
									
										196
									
								
								ts_tapbundle_protocol/protocol.emitter.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								ts_tapbundle_protocol/protocol.emitter.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | |||||||
|  | import type {  | ||||||
|  |   ITestResult,  | ||||||
|  |   ITestMetadata,  | ||||||
|  |   IPlanLine,  | ||||||
|  |   ISnapshotData, | ||||||
|  |   IErrorBlock, | ||||||
|  |   ITestEvent | ||||||
|  | } from './protocol.types.js'; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   PROTOCOL_MARKERS,  | ||||||
|  |   PROTOCOL_VERSION  | ||||||
|  | } from './protocol.types.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * ProtocolEmitter generates Protocol V2 messages | ||||||
|  |  * This class is used by tapbundle to emit test results in the new protocol format | ||||||
|  |  */ | ||||||
|  | export class ProtocolEmitter { | ||||||
|  |   /** | ||||||
|  |    * Emit protocol version header | ||||||
|  |    */ | ||||||
|  |   public emitProtocolHeader(): string { | ||||||
|  |     return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.PROTOCOL_PREFIX}${PROTOCOL_VERSION}${PROTOCOL_MARKERS.END}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit TAP version line | ||||||
|  |    */ | ||||||
|  |   public emitTapVersion(version: number = 13): string { | ||||||
|  |     return `TAP version ${version}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit test plan | ||||||
|  |    */ | ||||||
|  |   public emitPlan(plan: IPlanLine): string { | ||||||
|  |     if (plan.skipAll) { | ||||||
|  |       return `1..0 # Skipped: ${plan.skipAll}`; | ||||||
|  |     } | ||||||
|  |     return `${plan.start}..${plan.end}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit a test result | ||||||
|  |    */ | ||||||
|  |   public emitTest(result: ITestResult): string[] { | ||||||
|  |     const lines: string[] = []; | ||||||
|  |      | ||||||
|  |     // Build the basic TAP line | ||||||
|  |     let tapLine = result.ok ? 'ok' : 'not ok'; | ||||||
|  |     tapLine += ` ${result.testNumber}`; | ||||||
|  |     tapLine += ` - ${result.description}`; | ||||||
|  |      | ||||||
|  |     // Add directive if present | ||||||
|  |     if (result.directive) { | ||||||
|  |       tapLine += ` # ${result.directive.type.toUpperCase()}`; | ||||||
|  |       if (result.directive.reason) { | ||||||
|  |         tapLine += ` ${result.directive.reason}`; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Add inline metadata for simple cases | ||||||
|  |     if (result.metadata && this.shouldUseInlineMetadata(result.metadata)) { | ||||||
|  |       const metaStr = this.createInlineMetadata(result.metadata); | ||||||
|  |       if (metaStr) { | ||||||
|  |         tapLine += ` ${metaStr}`; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     lines.push(tapLine); | ||||||
|  |      | ||||||
|  |     // Add block metadata for complex cases | ||||||
|  |     if (result.metadata && !this.shouldUseInlineMetadata(result.metadata)) { | ||||||
|  |       lines.push(...this.createBlockMetadata(result.metadata, result.testNumber)); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return lines; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit a comment line | ||||||
|  |    */ | ||||||
|  |   public emitComment(comment: string): string { | ||||||
|  |     return `# ${comment}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit bailout | ||||||
|  |    */ | ||||||
|  |   public emitBailout(reason: string): string { | ||||||
|  |     return `Bail out! ${reason}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit snapshot data | ||||||
|  |    */ | ||||||
|  |   public emitSnapshot(snapshot: ISnapshotData): string[] { | ||||||
|  |     const lines: string[] = []; | ||||||
|  |     lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_PREFIX}${snapshot.name}${PROTOCOL_MARKERS.END}`); | ||||||
|  |      | ||||||
|  |     if (snapshot.format === 'json') { | ||||||
|  |       lines.push(JSON.stringify(snapshot.content, null, 2)); | ||||||
|  |     } else { | ||||||
|  |       lines.push(String(snapshot.content)); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_END}${PROTOCOL_MARKERS.END}`); | ||||||
|  |     return lines; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit error block | ||||||
|  |    */ | ||||||
|  |   public emitError(error: IErrorBlock): string[] { | ||||||
|  |     const lines: string[] = []; | ||||||
|  |     lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_PREFIX}${PROTOCOL_MARKERS.END}`); | ||||||
|  |     lines.push(JSON.stringify(error, null, 2)); | ||||||
|  |     lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_END}${PROTOCOL_MARKERS.END}`); | ||||||
|  |     return lines; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Emit test event | ||||||
|  |    */ | ||||||
|  |   public emitEvent(event: ITestEvent): string { | ||||||
|  |     const eventJson = JSON.stringify(event); | ||||||
|  |     return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.EVENT_PREFIX}${eventJson}${PROTOCOL_MARKERS.END}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if metadata should be inline | ||||||
|  |    */ | ||||||
|  |   private shouldUseInlineMetadata(metadata: ITestMetadata): boolean { | ||||||
|  |     // Use inline for simple metadata (time, retry, simple skip/todo) | ||||||
|  |     const hasComplexData = metadata.error ||  | ||||||
|  |                           metadata.custom ||  | ||||||
|  |                           (metadata.tags && metadata.tags.length > 0) || | ||||||
|  |                           metadata.file || | ||||||
|  |                           metadata.line; | ||||||
|  |      | ||||||
|  |     return !hasComplexData; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create inline metadata string | ||||||
|  |    */ | ||||||
|  |   private createInlineMetadata(metadata: ITestMetadata): string { | ||||||
|  |     const parts: string[] = []; | ||||||
|  |      | ||||||
|  |     if (metadata.time !== undefined) { | ||||||
|  |       parts.push(`time:${metadata.time}`); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if (metadata.retry !== undefined) { | ||||||
|  |       parts.push(`retry:${metadata.retry}`); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if (metadata.skip) { | ||||||
|  |       return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SKIP_PREFIX}${metadata.skip}${PROTOCOL_MARKERS.END}`; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if (metadata.todo) { | ||||||
|  |       return `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.TODO_PREFIX}${metadata.todo}${PROTOCOL_MARKERS.END}`; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if (parts.length > 0) { | ||||||
|  |       return `${PROTOCOL_MARKERS.START}${parts.join(',')}${PROTOCOL_MARKERS.END}`; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return ''; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create block metadata lines | ||||||
|  |    */ | ||||||
|  |   private createBlockMetadata(metadata: ITestMetadata, testNumber?: number): string[] { | ||||||
|  |     const lines: string[] = []; | ||||||
|  |      | ||||||
|  |     // Create a clean metadata object without skip/todo (handled inline) | ||||||
|  |     const blockMeta = { ...metadata }; | ||||||
|  |     delete blockMeta.skip; | ||||||
|  |     delete blockMeta.todo; | ||||||
|  |      | ||||||
|  |     // Emit metadata block | ||||||
|  |     const metaJson = JSON.stringify(blockMeta); | ||||||
|  |     lines.push(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.META_PREFIX}${metaJson}${PROTOCOL_MARKERS.END}`); | ||||||
|  |      | ||||||
|  |     // Emit separate error block if present | ||||||
|  |     if (metadata.error) { | ||||||
|  |       lines.push(...this.emitError({ testNumber, error: metadata.error })); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return lines; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										407
									
								
								ts_tapbundle_protocol/protocol.parser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										407
									
								
								ts_tapbundle_protocol/protocol.parser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,407 @@ | |||||||
|  | import type { | ||||||
|  |   ITestResult, | ||||||
|  |   ITestMetadata, | ||||||
|  |   IPlanLine, | ||||||
|  |   IProtocolMessage, | ||||||
|  |   ISnapshotData, | ||||||
|  |   IErrorBlock, | ||||||
|  |   ITestEvent | ||||||
|  | } from './protocol.types.js'; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   PROTOCOL_MARKERS | ||||||
|  | } from './protocol.types.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * ProtocolParser parses Protocol V2 messages | ||||||
|  |  * This class is used by tstest to parse test results from the new protocol format | ||||||
|  |  */ | ||||||
|  | export class ProtocolParser { | ||||||
|  |   private protocolVersion: string | null = null; | ||||||
|  |   private inBlock = false; | ||||||
|  |   private blockType: string | null = null; | ||||||
|  |   private blockContent: string[] = []; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse a single line and return protocol messages | ||||||
|  |    */ | ||||||
|  |   public parseLine(line: string): IProtocolMessage[] { | ||||||
|  |     const messages: IProtocolMessage[] = []; | ||||||
|  |  | ||||||
|  |     // Handle block content | ||||||
|  |     if (this.inBlock) { | ||||||
|  |       if (this.isBlockEnd(line)) { | ||||||
|  |         messages.push(this.finalizeBlock()); | ||||||
|  |         this.inBlock = false; | ||||||
|  |         this.blockType = null; | ||||||
|  |         this.blockContent = []; | ||||||
|  |       } else { | ||||||
|  |         this.blockContent.push(line); | ||||||
|  |       } | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check for block start | ||||||
|  |     if (this.isBlockStart(line)) { | ||||||
|  |       this.inBlock = true; | ||||||
|  |       this.blockType = this.extractBlockType(line); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check for protocol version | ||||||
|  |     const protocolVersion = this.parseProtocolVersion(line); | ||||||
|  |     if (protocolVersion) { | ||||||
|  |       this.protocolVersion = protocolVersion; | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'protocol', | ||||||
|  |         content: { version: protocolVersion } | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse TAP version | ||||||
|  |     const tapVersion = this.parseTapVersion(line); | ||||||
|  |     if (tapVersion !== null) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'version', | ||||||
|  |         content: tapVersion | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse plan | ||||||
|  |     const plan = this.parsePlan(line); | ||||||
|  |     if (plan) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'plan', | ||||||
|  |         content: plan | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse bailout | ||||||
|  |     const bailout = this.parseBailout(line); | ||||||
|  |     if (bailout) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'bailout', | ||||||
|  |         content: bailout | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse comment | ||||||
|  |     if (this.isComment(line)) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'comment', | ||||||
|  |         content: line.substring(2) // Remove "# " | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse test result | ||||||
|  |     const testResult = this.parseTestResult(line); | ||||||
|  |     if (testResult) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'test', | ||||||
|  |         content: testResult | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Parse event | ||||||
|  |     const event = this.parseEvent(line); | ||||||
|  |     if (event) { | ||||||
|  |       messages.push({ | ||||||
|  |         type: 'event', | ||||||
|  |         content: event | ||||||
|  |       }); | ||||||
|  |       return messages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return messages; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse protocol version header | ||||||
|  |    */ | ||||||
|  |   private parseProtocolVersion(line: string): string | null { | ||||||
|  |     const match = this.extractProtocolData(line, PROTOCOL_MARKERS.PROTOCOL_PREFIX); | ||||||
|  |     return match; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse TAP version line | ||||||
|  |    */ | ||||||
|  |   private parseTapVersion(line: string): number | null { | ||||||
|  |     const match = line.match(/^TAP version (\d+)$/); | ||||||
|  |     if (match) { | ||||||
|  |       return parseInt(match[1], 10); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse plan line | ||||||
|  |    */ | ||||||
|  |   private parsePlan(line: string): IPlanLine | null { | ||||||
|  |     // Skip all plan | ||||||
|  |     const skipMatch = line.match(/^1\.\.0\s*#\s*Skipped:\s*(.*)$/); | ||||||
|  |     if (skipMatch) { | ||||||
|  |       return { | ||||||
|  |         start: 1, | ||||||
|  |         end: 0, | ||||||
|  |         skipAll: skipMatch[1] | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Normal plan | ||||||
|  |     const match = line.match(/^(\d+)\.\.(\d+)$/); | ||||||
|  |     if (match) { | ||||||
|  |       return { | ||||||
|  |         start: parseInt(match[1], 10), | ||||||
|  |         end: parseInt(match[2], 10) | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse bailout | ||||||
|  |    */ | ||||||
|  |   private parseBailout(line: string): string | null { | ||||||
|  |     const match = line.match(/^Bail out!\s*(.*)$/); | ||||||
|  |     return match ? match[1] : null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse event | ||||||
|  |    */ | ||||||
|  |   private parseEvent(line: string): ITestEvent | null { | ||||||
|  |     const eventData = this.extractProtocolData(line, PROTOCOL_MARKERS.EVENT_PREFIX); | ||||||
|  |     if (eventData) { | ||||||
|  |       try { | ||||||
|  |         return JSON.parse(eventData) as ITestEvent; | ||||||
|  |       } catch (e) { | ||||||
|  |         // Invalid JSON, ignore | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if line is a comment | ||||||
|  |    */ | ||||||
|  |   private isComment(line: string): boolean { | ||||||
|  |     return line.startsWith('# '); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse test result line | ||||||
|  |    */ | ||||||
|  |   private parseTestResult(line: string): ITestResult | null { | ||||||
|  |     // First extract any inline metadata | ||||||
|  |     const metadata = this.extractInlineMetadata(line); | ||||||
|  |     const cleanLine = this.removeInlineMetadata(line); | ||||||
|  |  | ||||||
|  |     // Parse the TAP part | ||||||
|  |     const tapMatch = cleanLine.match(/^(ok|not ok)\s+(\d+)\s*-?\s*(.*)$/); | ||||||
|  |     if (!tapMatch) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result: ITestResult = { | ||||||
|  |       ok: tapMatch[1] === 'ok', | ||||||
|  |       testNumber: parseInt(tapMatch[2], 10), | ||||||
|  |       description: tapMatch[3].trim() | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Parse directive | ||||||
|  |     const directiveMatch = result.description.match(/^(.*?)\s*#\s*(SKIP|TODO)\s*(.*)$/i); | ||||||
|  |     if (directiveMatch) { | ||||||
|  |       result.description = directiveMatch[1].trim(); | ||||||
|  |       result.directive = { | ||||||
|  |         type: directiveMatch[2].toLowerCase() as 'skip' | 'todo', | ||||||
|  |         reason: directiveMatch[3] || undefined | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add metadata if found | ||||||
|  |     if (metadata) { | ||||||
|  |       result.metadata = metadata; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Extract inline metadata from line | ||||||
|  |    */ | ||||||
|  |   private extractInlineMetadata(line: string): ITestMetadata | null { | ||||||
|  |     const metadata: ITestMetadata = {}; | ||||||
|  |     let hasData = false; | ||||||
|  |  | ||||||
|  |     // Extract skip reason | ||||||
|  |     const skipData = this.extractProtocolData(line, PROTOCOL_MARKERS.SKIP_PREFIX); | ||||||
|  |     if (skipData) { | ||||||
|  |       metadata.skip = skipData; | ||||||
|  |       hasData = true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Extract todo reason | ||||||
|  |     const todoData = this.extractProtocolData(line, PROTOCOL_MARKERS.TODO_PREFIX); | ||||||
|  |     if (todoData) { | ||||||
|  |       metadata.todo = todoData; | ||||||
|  |       hasData = true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Extract META JSON | ||||||
|  |     const metaData = this.extractProtocolData(line, PROTOCOL_MARKERS.META_PREFIX); | ||||||
|  |     if (metaData) { | ||||||
|  |       try { | ||||||
|  |         Object.assign(metadata, JSON.parse(metaData)); | ||||||
|  |         hasData = true; | ||||||
|  |       } catch (e) { | ||||||
|  |         // Invalid JSON, ignore | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Extract simple key:value pairs | ||||||
|  |     const simpleMatch = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]+)${this.escapeRegex(PROTOCOL_MARKERS.END)}`)); | ||||||
|  |     if (simpleMatch && !simpleMatch[1].includes(':')) { | ||||||
|  |       // Not a prefixed format, might be key:value pairs | ||||||
|  |       const pairs = simpleMatch[1].split(','); | ||||||
|  |       for (const pair of pairs) { | ||||||
|  |         const [key, value] = pair.split(':'); | ||||||
|  |         if (key && value) { | ||||||
|  |           if (key === 'time') { | ||||||
|  |             metadata.time = parseInt(value, 10); | ||||||
|  |             hasData = true; | ||||||
|  |           } else if (key === 'retry') { | ||||||
|  |             metadata.retry = parseInt(value, 10); | ||||||
|  |             hasData = true; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return hasData ? metadata : null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Remove inline metadata from line | ||||||
|  |    */ | ||||||
|  |   private removeInlineMetadata(line: string): string { | ||||||
|  |     // Remove all protocol markers | ||||||
|  |     const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}[^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*${this.escapeRegex(PROTOCOL_MARKERS.END)}`, 'g'); | ||||||
|  |     return line.replace(regex, '').trim(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Extract protocol data with specific prefix | ||||||
|  |    */ | ||||||
|  |   private extractProtocolData(line: string, prefix: string): string | null { | ||||||
|  |     const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(prefix)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`); | ||||||
|  |     const match = line.match(regex); | ||||||
|  |     return match ? match[1] : null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if line starts a block | ||||||
|  |    */ | ||||||
|  |   private isBlockStart(line: string): boolean { | ||||||
|  |     // Only match if the line is exactly the block marker (after trimming) | ||||||
|  |     const trimmed = line.trim(); | ||||||
|  |     return trimmed === `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_PREFIX}${PROTOCOL_MARKERS.END}` || | ||||||
|  |            (trimmed.startsWith(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_PREFIX}`) &&  | ||||||
|  |             trimmed.endsWith(PROTOCOL_MARKERS.END) && | ||||||
|  |             !trimmed.includes(' ')); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if line ends a block | ||||||
|  |    */ | ||||||
|  |   private isBlockEnd(line: string): boolean { | ||||||
|  |     return line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_END}${PROTOCOL_MARKERS.END}`) || | ||||||
|  |            line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_END}${PROTOCOL_MARKERS.END}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Extract block type from start line | ||||||
|  |    */ | ||||||
|  |   private extractBlockType(line: string): string | null { | ||||||
|  |     if (line.includes(PROTOCOL_MARKERS.ERROR_PREFIX)) { | ||||||
|  |       return 'error'; | ||||||
|  |     } | ||||||
|  |     if (line.includes(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)) { | ||||||
|  |       const match = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`)); | ||||||
|  |       return match ? `snapshot:${match[1]}` : 'snapshot'; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Finalize current block | ||||||
|  |    */ | ||||||
|  |   private finalizeBlock(): IProtocolMessage { | ||||||
|  |     const content = this.blockContent.join('\n'); | ||||||
|  |  | ||||||
|  |     if (this.blockType === 'error') { | ||||||
|  |       try { | ||||||
|  |         const errorData = JSON.parse(content) as IErrorBlock; | ||||||
|  |         return { | ||||||
|  |           type: 'error', | ||||||
|  |           content: errorData | ||||||
|  |         }; | ||||||
|  |       } catch (e) { | ||||||
|  |         return { | ||||||
|  |           type: 'error', | ||||||
|  |           content: { error: { message: content } } | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (this.blockType?.startsWith('snapshot:')) { | ||||||
|  |       const name = this.blockType.substring(9); | ||||||
|  |       let parsedContent = content; | ||||||
|  |       let format: 'json' | 'text' = 'text'; | ||||||
|  |  | ||||||
|  |       try { | ||||||
|  |         parsedContent = JSON.parse(content); | ||||||
|  |         format = 'json'; | ||||||
|  |       } catch (e) { | ||||||
|  |         // Not JSON, keep as text | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         type: 'snapshot', | ||||||
|  |         content: { | ||||||
|  |           name, | ||||||
|  |           content: parsedContent, | ||||||
|  |           format | ||||||
|  |         } as ISnapshotData | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Fallback | ||||||
|  |     return { | ||||||
|  |       type: 'comment', | ||||||
|  |       content: content | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Escape regex special characters | ||||||
|  |    */ | ||||||
|  |   private escapeRegex(str: string): string { | ||||||
|  |     return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get protocol version | ||||||
|  |    */ | ||||||
|  |   public getProtocolVersion(): string | null { | ||||||
|  |     return this.protocolVersion; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										148
									
								
								ts_tapbundle_protocol/protocol.types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								ts_tapbundle_protocol/protocol.types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | // Protocol V2 Types and Interfaces | ||||||
|  | // This file contains all type definitions for the improved TAP protocol | ||||||
|  |  | ||||||
|  | export interface ITestMetadata { | ||||||
|  |   // 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; | ||||||
|  |     code?: string; | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   // 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 interface ITestResult { | ||||||
|  |   ok: boolean; | ||||||
|  |   testNumber: number; | ||||||
|  |   description: string; | ||||||
|  |   directive?: { | ||||||
|  |     type: 'skip' | 'todo'; | ||||||
|  |     reason?: string; | ||||||
|  |   }; | ||||||
|  |   metadata?: ITestMetadata; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IPlanLine { | ||||||
|  |   start: number; | ||||||
|  |   end: number; | ||||||
|  |   skipAll?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IProtocolMessage { | ||||||
|  |   type: 'test' | 'plan' | 'comment' | 'version' | 'bailout' | 'protocol' | 'snapshot' | 'error' | 'event'; | ||||||
|  |   content: any; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IProtocolVersion { | ||||||
|  |   version: string; | ||||||
|  |   features?: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface ISnapshotData { | ||||||
|  |   name: string; | ||||||
|  |   content: any; | ||||||
|  |   format?: 'json' | 'text' | 'binary'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IErrorBlock { | ||||||
|  |   testNumber?: number; | ||||||
|  |   error: { | ||||||
|  |     message: string; | ||||||
|  |     stack?: string; | ||||||
|  |     diff?: string; | ||||||
|  |     actual?: any; | ||||||
|  |     expected?: any; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Enhanced Communication Types | ||||||
|  | export type EventType =  | ||||||
|  |   | 'test:queued' | ||||||
|  |   | 'test:started'  | ||||||
|  |   | 'test:progress' | ||||||
|  |   | 'test:completed' | ||||||
|  |   | 'suite:started' | ||||||
|  |   | 'suite:completed' | ||||||
|  |   | 'hook:started' | ||||||
|  |   | 'hook:completed' | ||||||
|  |   | 'assertion:failed'; | ||||||
|  |  | ||||||
|  | export interface ITestEvent { | ||||||
|  |   eventType: EventType; | ||||||
|  |   timestamp: number; | ||||||
|  |   data: { | ||||||
|  |     testNumber?: number; | ||||||
|  |     description?: string; | ||||||
|  |     suiteName?: string; | ||||||
|  |     hookName?: string; | ||||||
|  |     progress?: number; // 0-100 | ||||||
|  |     duration?: number; | ||||||
|  |     error?: IEnhancedError; | ||||||
|  |     [key: string]: any; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IEnhancedError { | ||||||
|  |   message: string; | ||||||
|  |   stack?: string; | ||||||
|  |   diff?: IDiffResult; | ||||||
|  |   actual?: any; | ||||||
|  |   expected?: any; | ||||||
|  |   code?: string; | ||||||
|  |   type?: 'assertion' | 'timeout' | 'uncaught' | 'syntax' | 'runtime'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IDiffResult { | ||||||
|  |   type: 'string' | 'object' | 'array' | 'primitive'; | ||||||
|  |   changes: IDiffChange[]; | ||||||
|  |   context?: number; // lines of context | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface IDiffChange { | ||||||
|  |   type: 'add' | 'remove' | 'modify'; | ||||||
|  |   path?: string[]; // for object/array diffs | ||||||
|  |   oldValue?: any; | ||||||
|  |   newValue?: any; | ||||||
|  |   line?: number; // for string diffs | ||||||
|  |   content?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Protocol markers | ||||||
|  | export const PROTOCOL_MARKERS = { | ||||||
|  |   START: '⟦TSTEST:', | ||||||
|  |   END: '⟧', | ||||||
|  |   META_PREFIX: 'META:', | ||||||
|  |   ERROR_PREFIX: 'ERROR', | ||||||
|  |   ERROR_END: '/ERROR', | ||||||
|  |   SNAPSHOT_PREFIX: 'SNAPSHOT:', | ||||||
|  |   SNAPSHOT_END: '/SNAPSHOT', | ||||||
|  |   PROTOCOL_PREFIX: 'PROTOCOL:', | ||||||
|  |   SKIP_PREFIX: 'SKIP:', | ||||||
|  |   TODO_PREFIX: 'TODO:', | ||||||
|  |   EVENT_PREFIX: 'EVENT:', | ||||||
|  | } as const; | ||||||
|  |  | ||||||
|  | // Protocol version | ||||||
|  | export const PROTOCOL_VERSION = '2.0.0'; | ||||||
		Reference in New Issue
	
	Block a user