feat(core): Implement Protocol V2 with enhanced settings and lifecycle hooks
This commit is contained in:
parent
91880f8d42
commit
33d2ff1d4f
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
|
||||||
|
|
||||||
|
@ -125,4 +125,94 @@ 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
|
172
readme.plan.md
172
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
|
||||||
@ -252,4 +252,66 @@ tstest --changed
|
|||||||
- Clean interfaces between tstest and tapbundle
|
- Clean interfaces between tstest and tapbundle
|
||||||
- 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,137 +74,299 @@ 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
|
||||||
@ -353,4 +514,4 @@ export class TapParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const afterEach of afterEachFunctions) {
|
||||||
|
await afterEach(tapTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run global afterEach if configured
|
||||||
|
if (settings.afterEach) {
|
||||||
|
await settings.afterEach(testName, testPassed);
|
||||||
}
|
}
|
||||||
currentSuite = currentSuite.parent || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const afterEach of afterEachFunctions) {
|
|
||||||
await afterEach(tapTools);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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);
|
||||||
|
|
||||||
|
@ -22,6 +22,10 @@ export class TapTools {
|
|||||||
public testData: any = {};
|
public testData: any = {};
|
||||||
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;
|
||||||
@ -45,9 +49,33 @@ 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';
|
Loading…
x
Reference in New Issue
Block a user