Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
b7f4b7b3b8 | |||
424046b0de | |||
0f762f2063 | |||
82757c4abc | |||
7aaeed0dc6 | |||
c98bd85829 | |||
33d2ff1d4f | |||
91880f8d42 | |||
7b1732abcc | |||
7d09b39f2b | |||
96efba5903 | |||
3c535a8a77 | |||
0954265095 | |||
e1d90589bc | |||
33f705d961 | |||
13b11ab1bf | |||
63280e4a9a | |||
23addc2d2f | |||
3649114c8d | |||
2841aba8a4 | |||
31bf090410 | |||
b525754035 | |||
aa10fc4ab3 | |||
3eb8ef22e5 | |||
763dc89f59 | |||
e0d8ede450 | |||
27c950c1a1 | |||
83b324b09f | |||
63a2879cb4 | |||
1a375fa689 | |||
c48887a820 |
117
changelog.md
117
changelog.md
@ -1,5 +1,122 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-05-26 - 2.2.1 - fix(repo configuration)
|
||||
Update repository metadata to use 'git.zone' naming and add local permission settings
|
||||
|
||||
- Changed githost from 'gitlab.com' to 'code.foss.global' and gitscope from 'gitzone' to 'git.zone' in npmextra.json
|
||||
- Updated npm package name from '@gitzone/tstest' to '@git.zone/tstest' in npmextra.json and readme.md
|
||||
- Added .claude/settings.local.json with new permission configuration
|
||||
|
||||
## 2025-05-26 - 2.2.0 - feat(watch mode)
|
||||
Add watch mode support with CLI options and enhanced documentation
|
||||
|
||||
- Introduce '--watch' (or '-w') and '--watch-ignore' CLI flags for automatic test re-runs
|
||||
- Integrate @push.rocks/smartchok for file watching with 300ms debouncing
|
||||
- Update readme.md and readme.hints.md with detailed instructions and examples for watch mode
|
||||
- Add a demo test file (test/watch-demo/test.demo.ts) to illustrate the new feature
|
||||
- Add smartchok dependency in package.json
|
||||
|
||||
## 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)
|
||||
Introduce protocol v2 implementation and update build configuration with revised build order, new tspublish files, and enhanced documentation
|
||||
|
||||
- Added ts_tapbundle_protocol directory with isomorphic implementation for protocol v2
|
||||
- Updated readme.hints.md and readme.plan.md to explain the complete replacement of the v1 protocol and new build process
|
||||
- Revised build order in tspublish.json files across ts, ts_tapbundle, ts_tapbundle_node, and ts_tapbundle_protocol
|
||||
- Introduced .claude/settings.local.json with updated permission settings for CLI and build tools
|
||||
|
||||
## 2025-05-24 - 1.11.5 - fix(tstest)
|
||||
Fix timeout handling to correctly evaluate TAP results after killing the test process.
|
||||
|
||||
- Added call to evaluateFinalResult() after killing the process in runInNode to ensure final TAP output is processed.
|
||||
|
||||
## 2025-05-24 - 1.11.4 - fix(logging)
|
||||
Improve warning logging and add permission settings file
|
||||
|
||||
- Replace multiple logger.error calls with logger.warning for tests running over 1 minute
|
||||
- Add warning method in tstest logger to display warning messages consistently
|
||||
- Introduce .claude/settings.local.json to configure allowed permissions
|
||||
|
||||
## 2025-05-24 - 1.11.3 - fix(tstest)
|
||||
Add timeout warning for long-running tests and introduce local settings configuration
|
||||
|
||||
- Add .claude/settings.local.json with permission configuration for local development
|
||||
- Implement a timeout warning timer that notifies when tests run longer than 1 minute without an explicit timeout
|
||||
- Clear the timeout warning timer upon test completion
|
||||
- Remove unused import of logPrefixes in tstest.classes.tstest.ts
|
||||
|
||||
## 2025-05-24 - 1.11.2 - fix(tstest)
|
||||
Improve timeout and error handling in test execution along with TAP parser timeout logic improvements.
|
||||
|
||||
- In the TAP parser, ensure that expected tests are properly set when no tests are defined to avoid false negatives on timeout.
|
||||
- Use smartshell's terminate method and fallback kill to properly stop the entire process tree on timeout.
|
||||
- Clean up browser, server, and WebSocket instances reliably even when a timeout occurs.
|
||||
- Minor improvements in log file filtering and error logging for better clarity.
|
||||
|
||||
## 2025-05-24 - 1.11.1 - fix(tstest)
|
||||
Clear timeout identifiers after successful test execution and add local CLAUDE settings
|
||||
|
||||
- Ensure timeout IDs are cleared when tests complete to prevent lingering timeouts
|
||||
- Add .claude/settings.local.json with updated permission settings for CLI commands
|
||||
|
||||
## 2025-05-24 - 1.11.0 - feat(cli)
|
||||
Add new timeout and file range options with enhanced logfile diff logging
|
||||
|
||||
- Introduce --timeout <seconds> option to safeguard tests from running too long
|
||||
- Add --startFrom and --stopAt options to control the range of test files executed
|
||||
- Enhance logfile organization by automatically moving previous logs and generating diff reports for failed or changed test outputs
|
||||
- Update CLI argument parsing and internal timeout handling for both Node.js and browser tests
|
||||
|
||||
## 2025-05-24 - 1.10.2 - fix(tstest-logging)
|
||||
Improve log file handling with log rotation and diff reporting
|
||||
|
||||
- Add .claude/settings.local.json to configure allowed shell and web operations
|
||||
- Introduce movePreviousLogFiles function to archive previous log files when --logfile is used
|
||||
- Enhance logging to generate error copies and diff reports between current and previous logs
|
||||
- Add type annotations for console overrides in browser evaluations for improved stability
|
||||
|
||||
## 2025-05-23 - 1.10.1 - fix(tstest)
|
||||
Improve file range filtering and summary logging by skipping test files outside the specified range and reporting them in the final summary.
|
||||
|
||||
- Introduce runSingleTestOrSkip to check file index against startFrom/stopAt values.
|
||||
- Log skipped files with appropriate messages and add them to the summary.
|
||||
- Update the logger to include total skipped files in the test summary.
|
||||
- Add permission settings in .claude/settings.local.json to support new operations.
|
||||
|
||||
## 2025-05-23 - 1.10.0 - feat(cli)
|
||||
Add --startFrom and --stopAt options to filter test files by range
|
||||
|
||||
- Introduced CLI options --startFrom and --stopAt in ts/index.ts for selective test execution
|
||||
- Added validation to ensure provided range values are positive and startFrom is not greater than stopAt
|
||||
- Propagated file range filtering into test grouping in tstest.classes.tstest.ts, applying the range filter across serial and parallel groups
|
||||
- Updated usage messages to include the new options
|
||||
|
||||
## 2025-05-23 - 1.9.4 - fix(docs)
|
||||
Update documentation and configuration for legal notices and CI permissions. This commit adds a new local settings file for tool permissions, refines the legal and trademark sections in the readme, and improves glob test files with clearer log messages.
|
||||
|
||||
- Added .claude/settings.local.json to configure permissions for various CLI commands
|
||||
- Revised legal and trademark documentation in the readme to clarify company ownership and usage guidelines
|
||||
- Updated glob test files with improved console log messages for better clarity during test discovery
|
||||
|
||||
## 2025-05-23 - 1.9.3 - fix(tstest)
|
||||
Fix test timing display issue and update TAP protocol documentation
|
||||
|
||||
- Changed TAP parser regex to non-greedy pattern to correctly separate test timing metadata
|
||||
- Enhanced readme.hints.md with detailed explanation of test timing fix and planned protocol upgrades
|
||||
- Updated readme.md with improved usage examples for tapbundle and comprehensive test framework documentation
|
||||
- Added new protocol design document (readme.protocol.md) and improvement plan (readme.plan.md) outlining future changes
|
||||
- Introduced .claude/settings.local.json update for npm and CLI permissions
|
||||
- Exported protocol utilities and added tapbundle protocol implementation for future enhancements
|
||||
|
||||
## 2025-05-23 - 1.9.2 - fix(logging)
|
||||
Fix log file naming to prevent collisions and update logging system documentation.
|
||||
|
||||
|
19
license
Normal file
19
license
Normal file
@ -0,0 +1,19 @@
|
||||
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -6,11 +6,11 @@
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "gitzone",
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "git.zone",
|
||||
"gitrepo": "tstest",
|
||||
"description": "a test utility to run tests that match test/**/*.ts",
|
||||
"npmPackagename": "@gitzone/tstest",
|
||||
"npmPackagename": "@git.zone/tstest",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@git.zone/tstest",
|
||||
"version": "1.9.2",
|
||||
"version": "2.2.1",
|
||||
"private": false,
|
||||
"description": "a test utility to run tests that match test/**/*.ts",
|
||||
"exports": {
|
||||
@ -34,6 +34,7 @@
|
||||
"@push.rocks/consolecolor": "^2.0.2",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartbrowser": "^2.0.8",
|
||||
"@push.rocks/smartchok": "^1.0.34",
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartenv": "^5.0.12",
|
||||
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -26,6 +26,9 @@ importers:
|
||||
'@push.rocks/smartbrowser':
|
||||
specifier: ^2.0.8
|
||||
version: 2.0.8
|
||||
'@push.rocks/smartchok':
|
||||
specifier: ^1.0.34
|
||||
version: 1.0.34
|
||||
'@push.rocks/smartcrypto':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
|
199
readme.hints.md
199
readme.hints.md
@ -40,9 +40,17 @@ This project integrates tstest with tapbundle through a modular architecture:
|
||||
- Automatically detects browser environment and only enables in browser context
|
||||
|
||||
3. **Build System**
|
||||
- Uses `tsbuild tsfolders` to compile TypeScript
|
||||
- Maintains separate output directories: `/dist_ts/`, `/dist_ts_tapbundle/`, `/dist_ts_tapbundle_node/`
|
||||
- Compilation order is resolved automatically based on dependencies
|
||||
- Uses `tsbuild tsfolders` to compile TypeScript (invoked by `pnpm build`)
|
||||
- Maintains separate output directories: `/dist_ts/`, `/dist_ts_tapbundle/`, `/dist_ts_tapbundle_node/`, `/dist_ts_tapbundle_protocol/`
|
||||
- Compilation order is resolved automatically based on dependencies in tspublish.json files
|
||||
- Protocol imports use compiled dist directories:
|
||||
```typescript
|
||||
// In ts/tstest.classes.tap.parser.ts
|
||||
import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
|
||||
// In ts_tapbundle/tapbundle.classes.tap.ts
|
||||
import { ProtocolEmitter } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
```
|
||||
|
||||
### Test Scripts
|
||||
|
||||
@ -74,4 +82,187 @@ This fix ensures that test files with the same basename in different directories
|
||||
1. Takes the relative path from the current working directory
|
||||
2. Replaces path separators (`/`) with double underscores (`__`)
|
||||
3. Removes the `.ts` extension
|
||||
4. Creates a flat filename that preserves the directory structure
|
||||
4. Creates a flat filename that preserves the directory structure
|
||||
|
||||
### Test Timing Display (Fixed in v1.9.2)
|
||||
|
||||
Fixed an issue where test timing was displayed incorrectly with duplicate values like:
|
||||
- Before: `✅ test name # time=133ms (0ms)`
|
||||
- After: `✅ test name (133ms)`
|
||||
|
||||
The issue was in the TAP parser regex which was greedily capturing the entire line including the TAP timing comment. Changed the regex from `(.*)` to `(.*?)` to make it non-greedy, properly separating the test name from the timing metadata.
|
||||
|
||||
## Protocol Limitations and Improvements
|
||||
|
||||
### Current TAP Protocol Issues
|
||||
The current implementation uses standard TAP format with metadata in comments:
|
||||
```
|
||||
ok 1 - test name # time=123ms
|
||||
```
|
||||
|
||||
This has several limitations:
|
||||
1. **Delimiter Conflict**: Test descriptions containing `#` can break parsing
|
||||
2. **Regex Fragility**: Complex regex patterns that are hard to maintain
|
||||
3. **Limited Metadata**: Difficult to add rich error information or custom data
|
||||
|
||||
### Planned Protocol V2
|
||||
A new internal protocol is being designed that will:
|
||||
- Use Unicode delimiters `⟦TSTEST:⟧` that won't conflict with test content
|
||||
- Support structured JSON metadata
|
||||
- Allow rich error reporting with stack traces and diffs
|
||||
- Completely replace v1 protocol (no backwards compatibility)
|
||||
|
||||
### ts_tapbundle_protocol Directory
|
||||
The protocol v2 implementation is contained in a separate `ts_tapbundle_protocol` directory:
|
||||
- **Isomorphic Code**: All protocol code works in both browser and Node.js environments
|
||||
- **No Platform Dependencies**: No Node.js-specific imports, ensuring true cross-platform compatibility
|
||||
- **Clean Separation**: Protocol logic is isolated from platform-specific code in tstest and tapbundle
|
||||
- **Shared Implementation**: Both tstest (parser) and tapbundle (emitter) use the same protocol classes
|
||||
- **Build Process**:
|
||||
- Compiled by `pnpm build` via tsbuild to `dist_ts_tapbundle_protocol/`
|
||||
- Build order managed through tspublish.json files
|
||||
- Other modules import from the compiled dist directory, not source
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
## Watch Mode (Phase 4)
|
||||
|
||||
tstest now supports watch mode for automatic test re-runs on file changes.
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
tstest test/**/*.ts --watch
|
||||
tstest test/specific.ts -w
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Automatic Re-runs**: Tests re-run when any watched file changes
|
||||
- **Debouncing**: Multiple rapid changes are batched (300ms delay)
|
||||
- **Clear Output**: Console is cleared before each run for clean results
|
||||
- **Status Updates**: Shows which files triggered the re-run
|
||||
- **Graceful Exit**: Press Ctrl+C to stop watching
|
||||
|
||||
### Options
|
||||
- `--watch` or `-w`: Enable watch mode
|
||||
- `--watch-ignore`: Comma-separated patterns to ignore (e.g., `--watch-ignore node_modules,dist`)
|
||||
|
||||
### Implementation Details
|
||||
- Uses `@push.rocks/smartchok` for cross-platform file watching
|
||||
- Watches the entire project directory from where tests are run
|
||||
- Ignores changes matching the ignore patterns
|
||||
- Shows "Waiting for file changes..." between runs
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### tap.skip.test(), tap.todo(), and tap.only.test() (Fixed)
|
||||
|
||||
Previously reported issues with these methods have been resolved:
|
||||
|
||||
1. **tap.skip.test()** - Now properly creates test objects that are counted in test results
|
||||
- Tests marked with `skip.test()` appear in the test count
|
||||
- Shows as passed with skip directive in TAP output
|
||||
- `markAsSkipped()` method added to handle pre-test skip marking
|
||||
|
||||
2. **tap.todo.test()** - Fully implemented with test object creation
|
||||
- Supports both `tap.todo.test('description')` and `tap.todo.test('description', testFunc)`
|
||||
- Todo tests are counted and marked with todo directive
|
||||
- Both regular and parallel todo tests supported
|
||||
|
||||
3. **tap.only.test()** - Works correctly for focused testing
|
||||
- When `.only` tests exist, only those tests run
|
||||
- Other tests are not executed but still counted
|
||||
- Both regular and parallel only tests supported
|
||||
|
||||
These fixes ensure accurate test counts and proper TAP-compliant output for all test states.
|
735
readme.md
735
readme.md
@ -1,4 +1,4 @@
|
||||
# @gitzone/tstest
|
||||
# @git.zone/tstest
|
||||
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative!
|
||||
|
||||
## Availabililty and Links
|
||||
@ -27,6 +27,12 @@
|
||||
- 🔁 **Retry Logic** - Automatically retry failing tests
|
||||
- 🛠️ **Test Fixtures** - Create reusable test data
|
||||
- 📦 **Browser-Compatible** - Full browser support with embedded tapbundle
|
||||
- 👀 **Watch Mode** - Automatically re-run tests on file changes
|
||||
- 📊 **Real-time Progress** - Live test execution progress updates
|
||||
- 🎨 **Visual Diffs** - Beautiful side-by-side diffs for failed assertions
|
||||
- 🔄 **Event-based Reporting** - Real-time test lifecycle events
|
||||
- ⚙️ **Test Configuration** - Flexible test settings with .tstest.json files
|
||||
- 🚀 **Protocol V2** - Enhanced TAP protocol with Unicode delimiters
|
||||
|
||||
## Installation
|
||||
|
||||
@ -68,8 +74,14 @@ tstest "test/unit/*.ts"
|
||||
| `--verbose`, `-v` | Show all console output from tests |
|
||||
| `--no-color` | Disable colored output |
|
||||
| `--json` | Output results as JSON |
|
||||
| `--logfile` | Save detailed logs to `.nogit/testlogs/[testname].log` |
|
||||
| `--logfile` | Save detailed logs with automatic error and diff tracking |
|
||||
| `--tags <tags>` | Run only tests with specific tags (comma-separated) |
|
||||
| `--timeout <seconds>` | Timeout test files after specified seconds |
|
||||
| `--startFrom <n>` | Start running from test file number n |
|
||||
| `--stopAt <n>` | Stop running at test file number n |
|
||||
| `--watch`, `-w` | Watch for file changes and re-run tests |
|
||||
| `--watch-ignore <patterns>` | Ignore patterns in watch mode (comma-separated) |
|
||||
| `--only` | Run only tests marked with .only |
|
||||
|
||||
### Example Outputs
|
||||
|
||||
@ -141,9 +153,9 @@ tstest supports different test environments through file naming:
|
||||
| `*.browser.ts` | Browser environment | `test.ui.browser.ts` |
|
||||
| `*.both.ts` | Both Node.js and browser | `test.isomorphic.both.ts` |
|
||||
|
||||
### Writing Tests
|
||||
### Writing Tests with tapbundle
|
||||
|
||||
tstest includes a built-in TAP (Test Anything Protocol) test framework. Import it from the embedded tapbundle:
|
||||
tstest includes tapbundle, a powerful TAP-based test framework. Import it from the embedded tapbundle:
|
||||
|
||||
```typescript
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
@ -164,103 +176,504 @@ tstest provides multiple exports for different use cases:
|
||||
- `@git.zone/tstest/tapbundle` - Browser-compatible test framework
|
||||
- `@git.zone/tstest/tapbundle_node` - Node.js-specific test utilities
|
||||
|
||||
#### Test Features
|
||||
## tapbundle Test Framework
|
||||
|
||||
### Basic Test Syntax
|
||||
|
||||
**Tag-based Test Filtering**
|
||||
```typescript
|
||||
tap.tags('unit', 'api')
|
||||
.test('should handle API requests', async () => {
|
||||
// Test code
|
||||
});
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
// Run with: tstest test/ --tags unit,api
|
||||
```
|
||||
|
||||
**Test Lifecycle Hooks**
|
||||
```typescript
|
||||
tap.describe('User API Tests', () => {
|
||||
let testUser;
|
||||
|
||||
tap.beforeEach(async () => {
|
||||
testUser = await createTestUser();
|
||||
});
|
||||
|
||||
tap.afterEach(async () => {
|
||||
await deleteTestUser(testUser.id);
|
||||
});
|
||||
|
||||
tap.test('should update user profile', async () => {
|
||||
// Test code using testUser
|
||||
});
|
||||
// Basic test
|
||||
tap.test('should perform basic arithmetic', async () => {
|
||||
expect(2 + 2).toEqual(4);
|
||||
});
|
||||
```
|
||||
|
||||
**Parallel Test Execution**
|
||||
```typescript
|
||||
// Files with matching parallel group names run concurrently
|
||||
// test.auth.para__1.ts
|
||||
tap.test('authentication test', async () => { /* ... */ });
|
||||
|
||||
// test.user.para__1.ts
|
||||
tap.test('user operations test', async () => { /* ... */ });
|
||||
```
|
||||
|
||||
**Test Timeouts and Retries**
|
||||
```typescript
|
||||
tap.timeout(5000)
|
||||
.retry(3)
|
||||
.test('flaky network test', async (tools) => {
|
||||
// This test has 5 seconds to complete and will retry up to 3 times
|
||||
});
|
||||
```
|
||||
|
||||
**Snapshot Testing**
|
||||
```typescript
|
||||
tap.test('should match snapshot', async (tools) => {
|
||||
const result = await generateReport();
|
||||
await tools.matchSnapshot(result);
|
||||
// Async test with tools
|
||||
tap.test('async operations', async (tools) => {
|
||||
await tools.delayFor(100); // delay for 100ms
|
||||
const result = await fetchData();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
// Start test execution
|
||||
tap.start();
|
||||
```
|
||||
|
||||
**Test Fixtures**
|
||||
```typescript
|
||||
// Define a reusable fixture
|
||||
tap.defineFixture('testUser', async () => ({
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com'
|
||||
}));
|
||||
### Test Modifiers and Chaining
|
||||
|
||||
tap.test('user test', async (tools) => {
|
||||
const user = tools.fixture('testUser');
|
||||
expect(user.name).toEqual('Test User');
|
||||
});
|
||||
```
|
||||
|
||||
**Skipping and Todo Tests**
|
||||
```typescript
|
||||
tap.skip.test('work in progress', async () => {
|
||||
// Skip a test
|
||||
tap.skip.test('not ready yet', async () => {
|
||||
// This test will be skipped
|
||||
});
|
||||
|
||||
tap.todo('implement user deletion', async () => {
|
||||
// This marks a test as todo
|
||||
// Run only this test (exclusive)
|
||||
tap.only.test('focus on this', async () => {
|
||||
// Only this test will run
|
||||
});
|
||||
|
||||
// Todo test - creates actual test object marked as todo
|
||||
tap.todo.test('implement later', async () => {
|
||||
// This test will be counted but marked as todo
|
||||
});
|
||||
|
||||
// Chaining modifiers
|
||||
tap.timeout(5000)
|
||||
.retry(3)
|
||||
.tags('api', 'integration')
|
||||
.test('complex test', async (tools) => {
|
||||
// Test with 5s timeout, 3 retries, and tags
|
||||
});
|
||||
```
|
||||
|
||||
### Test Organization with describe()
|
||||
|
||||
```typescript
|
||||
tap.describe('User Management', () => {
|
||||
let testDatabase;
|
||||
|
||||
tap.beforeEach(async () => {
|
||||
testDatabase = await createTestDB();
|
||||
});
|
||||
|
||||
tap.afterEach(async () => {
|
||||
await testDatabase.cleanup();
|
||||
});
|
||||
|
||||
tap.test('should create user', async () => {
|
||||
const user = await testDatabase.createUser({ name: 'John' });
|
||||
expect(user.id).toBeDefined();
|
||||
});
|
||||
|
||||
tap.describe('User Permissions', () => {
|
||||
tap.test('should set admin role', async () => {
|
||||
// Nested describe blocks
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Browser Testing**
|
||||
### Test Tools (Available in Test Function)
|
||||
|
||||
Every test function receives a `tools` parameter with utilities:
|
||||
|
||||
```typescript
|
||||
tap.test('using test tools', async (tools) => {
|
||||
// Delay utilities
|
||||
await tools.delayFor(1000); // delay for 1000ms
|
||||
await tools.delayForRandom(100, 500); // random delay between 100-500ms
|
||||
|
||||
// Skip test conditionally
|
||||
tools.skipIf(process.env.CI === 'true', 'Skipping in CI');
|
||||
|
||||
// Skip test unconditionally
|
||||
if (!apiKeyAvailable) {
|
||||
tools.skip('API key not available');
|
||||
}
|
||||
|
||||
// Mark as todo
|
||||
tools.todo('Needs implementation');
|
||||
|
||||
// Retry configuration
|
||||
tools.retry(3); // Set retry count
|
||||
|
||||
// Timeout configuration
|
||||
tools.timeout(10000); // Set timeout to 10s
|
||||
|
||||
// Context sharing between tests
|
||||
tools.context.set('userId', 12345);
|
||||
const userId = tools.context.get('userId');
|
||||
|
||||
// Deferred promises
|
||||
const deferred = tools.defer();
|
||||
setTimeout(() => deferred.resolve('done'), 100);
|
||||
await deferred.promise;
|
||||
|
||||
// Colored console output
|
||||
const coloredString = await tools.coloredString('Success!', 'green');
|
||||
console.log(coloredString);
|
||||
|
||||
// Error handling helper
|
||||
const error = await tools.returnError(async () => {
|
||||
throw new Error('Expected error');
|
||||
});
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
```typescript
|
||||
tap.test('snapshot test', async (tools) => {
|
||||
const output = generateComplexOutput();
|
||||
|
||||
// Compare with saved snapshot
|
||||
await tools.matchSnapshot(output);
|
||||
|
||||
// Named snapshots for multiple checks in one test
|
||||
await tools.matchSnapshot(output.header, 'header');
|
||||
await tools.matchSnapshot(output.body, 'body');
|
||||
});
|
||||
|
||||
// Update snapshots with: UPDATE_SNAPSHOTS=true tstest test/
|
||||
```
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
```typescript
|
||||
// Define reusable fixtures
|
||||
tap.defineFixture('testUser', async (data) => ({
|
||||
id: Date.now(),
|
||||
name: data?.name || 'Test User',
|
||||
email: data?.email || 'test@example.com',
|
||||
created: new Date()
|
||||
}));
|
||||
|
||||
tap.defineFixture('testPost', async (data) => ({
|
||||
id: Date.now(),
|
||||
title: data?.title || 'Test Post',
|
||||
authorId: data?.authorId || 1
|
||||
}));
|
||||
|
||||
// Use fixtures in tests
|
||||
tap.test('fixture test', async (tools) => {
|
||||
const user = await tools.fixture('testUser', { name: 'John' });
|
||||
const post = await tools.fixture('testPost', { authorId: user.id });
|
||||
|
||||
expect(post.authorId).toEqual(user.id);
|
||||
|
||||
// Factory pattern for multiple instances
|
||||
const users = await tools.factory('testUser').createMany(5);
|
||||
expect(users).toHaveLength(5);
|
||||
});
|
||||
```
|
||||
|
||||
### Parallel Test Execution
|
||||
|
||||
```typescript
|
||||
// Parallel tests within a file
|
||||
tap.testParallel('parallel test 1', async () => {
|
||||
await heavyOperation();
|
||||
});
|
||||
|
||||
tap.testParallel('parallel test 2', async () => {
|
||||
await anotherHeavyOperation();
|
||||
});
|
||||
|
||||
// File naming for parallel groups
|
||||
// test.api.para__1.ts - runs in parallel with other para__1 files
|
||||
// test.db.para__1.ts - runs in parallel with other para__1 files
|
||||
// test.auth.para__2.ts - runs after para__1 group completes
|
||||
```
|
||||
|
||||
### Assertions with expect()
|
||||
|
||||
tapbundle uses @push.rocks/smartexpect for assertions:
|
||||
|
||||
```typescript
|
||||
// Basic assertions
|
||||
expect(value).toEqual(5);
|
||||
expect(value).not.toEqual(10);
|
||||
expect(obj).toDeepEqual({ a: 1, b: 2 });
|
||||
|
||||
// Type assertions
|
||||
expect('hello').toBeTypeofString();
|
||||
expect(42).toBeTypeofNumber();
|
||||
expect(true).toBeTypeofBoolean();
|
||||
expect([]).toBeArray();
|
||||
expect({}).toBeTypeOf('object');
|
||||
|
||||
// Comparison assertions
|
||||
expect(5).toBeGreaterThan(3);
|
||||
expect(3).toBeLessThan(5);
|
||||
expect(5).toBeGreaterThanOrEqual(5);
|
||||
expect(5).toBeLessThanOrEqual(5);
|
||||
expect(0.1 + 0.2).toBeCloseTo(0.3, 10);
|
||||
|
||||
// Truthiness
|
||||
expect(true).toBeTrue();
|
||||
expect(false).toBeFalse();
|
||||
expect('text').toBeTruthy();
|
||||
expect(0).toBeFalsy();
|
||||
expect(null).toBeNull();
|
||||
expect(undefined).toBeUndefined();
|
||||
expect(null).toBeNullOrUndefined();
|
||||
|
||||
// String assertions
|
||||
expect('hello world').toStartWith('hello');
|
||||
expect('hello world').toEndWith('world');
|
||||
expect('hello world').toInclude('lo wo');
|
||||
expect('hello world').toMatch(/^hello/);
|
||||
expect('option').toBeOneOf(['choice', 'option', 'alternative']);
|
||||
|
||||
// Array assertions
|
||||
expect([1, 2, 3]).toContain(2);
|
||||
expect([1, 2, 3]).toContainAll([1, 3]);
|
||||
expect([1, 2, 3]).toExclude(4);
|
||||
expect([1, 2, 3]).toHaveLength(3);
|
||||
expect([]).toBeEmptyArray();
|
||||
expect([{ id: 1 }]).toContainEqual({ id: 1 });
|
||||
|
||||
// Object assertions
|
||||
expect(obj).toHaveProperty('name');
|
||||
expect(obj).toHaveProperty('user.email', 'test@example.com');
|
||||
expect(obj).toHaveDeepProperty(['level1', 'level2']);
|
||||
expect(obj).toMatchObject({ name: 'John' });
|
||||
|
||||
// Function assertions
|
||||
expect(() => { throw new Error('test'); }).toThrow();
|
||||
expect(() => { throw new Error('test'); }).toThrow(Error);
|
||||
expect(() => { throw new Error('test error'); }).toThrowErrorMatching(/test/);
|
||||
expect(myFunction).not.toThrow();
|
||||
|
||||
// Promise assertions
|
||||
await expect(Promise.resolve('value')).resolves.toEqual('value');
|
||||
await expect(Promise.reject(new Error('fail'))).rejects.toThrow();
|
||||
|
||||
// Custom assertions
|
||||
expect(7).customAssertion(
|
||||
value => value % 2 === 1,
|
||||
'Value is not odd'
|
||||
);
|
||||
```
|
||||
|
||||
### Pre-tasks
|
||||
|
||||
Run setup tasks before tests start:
|
||||
|
||||
```typescript
|
||||
tap.preTask('setup database', async () => {
|
||||
await initializeTestDatabase();
|
||||
console.log('Database initialized');
|
||||
});
|
||||
|
||||
tap.preTask('load environment', async () => {
|
||||
await loadTestEnvironment();
|
||||
});
|
||||
|
||||
// Pre-tasks run in order before any tests
|
||||
```
|
||||
|
||||
### Tag-based Test Filtering
|
||||
|
||||
```typescript
|
||||
// Tag individual tests
|
||||
tap.tags('unit', 'api')
|
||||
.test('api unit test', async () => {
|
||||
// Test code
|
||||
});
|
||||
|
||||
tap.tags('integration', 'slow')
|
||||
.test('database integration', async () => {
|
||||
// Test code
|
||||
});
|
||||
|
||||
// Run only tests with specific tags
|
||||
// tstest test/ --tags unit,api
|
||||
```
|
||||
|
||||
### Context Sharing
|
||||
|
||||
Share data between tests:
|
||||
|
||||
```typescript
|
||||
tap.test('first test', async (tools) => {
|
||||
const sessionId = await createSession();
|
||||
tools.context.set('sessionId', sessionId);
|
||||
});
|
||||
|
||||
tap.test('second test', async (tools) => {
|
||||
const sessionId = tools.context.get('sessionId');
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
tools.context.delete('sessionId');
|
||||
});
|
||||
```
|
||||
|
||||
### Browser Testing with webhelpers
|
||||
|
||||
For browser-specific tests:
|
||||
|
||||
```typescript
|
||||
// test.browser.ts
|
||||
import { tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
tap.test('DOM manipulation', async () => {
|
||||
// Create DOM elements from HTML strings
|
||||
const element = await webhelpers.fixture(webhelpers.html`
|
||||
<div>Hello World</div>
|
||||
<div class="test-container">
|
||||
<h1>Test Title</h1>
|
||||
<button id="test-btn">Click Me</button>
|
||||
</div>
|
||||
`);
|
||||
expect(element).toBeInstanceOf(HTMLElement);
|
||||
|
||||
expect(element.querySelector('h1').textContent).toEqual('Test Title');
|
||||
|
||||
// Simulate interactions
|
||||
const button = element.querySelector('#test-btn');
|
||||
button.click();
|
||||
});
|
||||
|
||||
tap.test('CSS testing', async () => {
|
||||
const styles = webhelpers.css`
|
||||
.test-class {
|
||||
color: red;
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
// styles is a string that can be injected into the page
|
||||
expect(styles).toInclude('color: red');
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Error Handling
|
||||
|
||||
```typescript
|
||||
tap.test('error handling', async (tools) => {
|
||||
// Capture errors without failing the test
|
||||
const error = await tools.returnError(async () => {
|
||||
await functionThatThrows();
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toEqual('Expected error message');
|
||||
});
|
||||
```
|
||||
|
||||
### Test Wrap
|
||||
|
||||
Create wrapped test environments:
|
||||
|
||||
```typescript
|
||||
import { TapWrap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
const tapWrap = new TapWrap({
|
||||
before: async () => {
|
||||
console.log('Before all tests');
|
||||
await globalSetup();
|
||||
},
|
||||
after: async () => {
|
||||
console.log('After all tests');
|
||||
await globalCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Tests registered here will have the wrap lifecycle
|
||||
tapWrap.tap.test('wrapped test', async () => {
|
||||
// This test runs with the wrap setup/teardown
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Watch Mode
|
||||
|
||||
Automatically re-run tests when files change:
|
||||
|
||||
```bash
|
||||
# Watch all files in the project
|
||||
tstest test/ --watch
|
||||
|
||||
# Watch with custom ignore patterns
|
||||
tstest test/ --watch --watch-ignore "dist/**,coverage/**"
|
||||
|
||||
# Short form
|
||||
tstest test/ -w
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- 👀 Shows which files triggered the re-run
|
||||
- ⏱️ 300ms debouncing to batch rapid changes
|
||||
- 🔄 Clears console between runs for clean output
|
||||
- 📁 Intelligently ignores common non-source files
|
||||
|
||||
### Real-time Test Progress
|
||||
|
||||
tstest provides real-time updates during test execution:
|
||||
|
||||
```
|
||||
▶️ test/api.test.ts (1/4)
|
||||
Runtime: node.js
|
||||
⏳ Running: api endpoint validation...
|
||||
✅ api endpoint validation (145ms)
|
||||
⏳ Running: error handling...
|
||||
✅ error handling (23ms)
|
||||
Summary: 2/2 PASSED
|
||||
```
|
||||
|
||||
### Visual Diffs for Failed Assertions
|
||||
|
||||
When assertions fail, tstest shows beautiful side-by-side diffs:
|
||||
|
||||
```
|
||||
❌ should return correct user data
|
||||
|
||||
String Diff:
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
- Hello World
|
||||
+ Hello Universe
|
||||
|
||||
Object Diff:
|
||||
{
|
||||
name: "John",
|
||||
- age: 30,
|
||||
+ age: 31,
|
||||
email: "john@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Test Configuration (.tstest.json)
|
||||
|
||||
Configure test behavior with `.tstest.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"timeout": 30000,
|
||||
"retries": 2,
|
||||
"bail": false,
|
||||
"parallel": true,
|
||||
"tags": ["unit", "fast"],
|
||||
"env": {
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Configuration files are discovered in:
|
||||
1. Test file directory
|
||||
2. Parent directories (up to project root)
|
||||
3. Project root
|
||||
4. Home directory (`~/.tstest.json`)
|
||||
|
||||
Settings cascade and merge, with closer files taking precedence.
|
||||
|
||||
### Event-based Test Reporting
|
||||
|
||||
tstest emits detailed events during test execution for integration with CI/CD tools:
|
||||
|
||||
```json
|
||||
{"event":"suite:started","file":"test/api.test.ts","timestamp":"2025-05-26T10:30:00.000Z"}
|
||||
{"event":"test:started","name":"api endpoint validation","timestamp":"2025-05-26T10:30:00.100Z"}
|
||||
{"event":"test:progress","name":"api endpoint validation","message":"Validating response schema"}
|
||||
{"event":"test:completed","name":"api endpoint validation","passed":true,"duration":145}
|
||||
{"event":"suite:completed","file":"test/api.test.ts","passed":true,"total":2,"failed":0}
|
||||
```
|
||||
|
||||
### Enhanced TAP Protocol (Protocol V2)
|
||||
|
||||
tstest uses an enhanced TAP protocol with Unicode delimiters for better parsing:
|
||||
|
||||
```
|
||||
⟦TSTEST:EVENT:test:started⟧{"name":"my test","timestamp":"2025-05-26T10:30:00.000Z"}
|
||||
ok 1 my test
|
||||
⟦TSTEST:EVENT:test:completed⟧{"name":"my test","passed":true,"duration":145}
|
||||
```
|
||||
|
||||
This prevents conflicts with test output that might contain TAP-like formatting.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Glob Pattern Support
|
||||
@ -279,14 +692,88 @@ tstest "test/**/*.spec.ts" "test/**/*.test.ts"
|
||||
|
||||
**Important**: Always quote glob patterns to prevent shell expansion. Without quotes, the shell will expand the pattern and only pass the first matching file to tstest.
|
||||
|
||||
### Automatic Logging
|
||||
### Enhanced Test Logging
|
||||
|
||||
The `--logfile` option provides intelligent test logging with automatic organization:
|
||||
|
||||
Use `--logfile` to automatically save test output:
|
||||
```bash
|
||||
tstest test/ --logfile
|
||||
```
|
||||
|
||||
This creates detailed logs in `.nogit/testlogs/[testname].log` for each test file.
|
||||
**Log Organization:**
|
||||
- **Current Run**: `.nogit/testlogs/[testname].log`
|
||||
- **Previous Run**: `.nogit/testlogs/previous/[testname].log`
|
||||
- **Failed Tests**: `.nogit/testlogs/00err/[testname].log`
|
||||
- **Changed Output**: `.nogit/testlogs/00diff/[testname].log`
|
||||
|
||||
**Features:**
|
||||
- Previous logs are automatically moved to the `previous/` folder
|
||||
- Failed tests create copies in `00err/` for quick identification
|
||||
- Tests with changed output create diff reports in `00diff/`
|
||||
- The `00err/` and `00diff/` folders are cleared on each run
|
||||
|
||||
**Example Diff Report:**
|
||||
```
|
||||
DIFF REPORT: test__api__integration.log
|
||||
Generated: 2025-05-24T01:29:13.847Z
|
||||
================================================================================
|
||||
|
||||
- [Line 8] ✅ api test passes (150ms)
|
||||
+ [Line 8] ✅ api test passes (165ms)
|
||||
|
||||
================================================================================
|
||||
Previous version had 40 lines
|
||||
Current version has 40 lines
|
||||
```
|
||||
|
||||
### Test Timeout Protection
|
||||
|
||||
Prevent runaway tests with the `--timeout` option:
|
||||
|
||||
```bash
|
||||
# Timeout any test file that runs longer than 60 seconds
|
||||
tstest test/ --timeout 60
|
||||
|
||||
# Shorter timeout for unit tests
|
||||
tstest test/unit/ --timeout 10
|
||||
```
|
||||
|
||||
When a test exceeds the timeout:
|
||||
- The test process is terminated (SIGTERM)
|
||||
- The test is marked as failed
|
||||
- An error log is created in `.nogit/testlogs/00err/`
|
||||
- Clear error message shows the timeout duration
|
||||
|
||||
### Test File Range Control
|
||||
|
||||
Run specific ranges of test files using `--startFrom` and `--stopAt`:
|
||||
|
||||
```bash
|
||||
# Run tests starting from the 5th file
|
||||
tstest test/ --startFrom 5
|
||||
|
||||
# Run only files 5 through 10
|
||||
tstest test/ --startFrom 5 --stopAt 10
|
||||
|
||||
# Run only the first 3 test files
|
||||
tstest test/ --stopAt 3
|
||||
```
|
||||
|
||||
This is particularly useful for:
|
||||
- Debugging specific test failures in large test suites
|
||||
- Running tests in chunks on different CI runners
|
||||
- Quickly testing changes to specific test files
|
||||
|
||||
The output shows which files are skipped:
|
||||
```
|
||||
⏭️ test/auth.test.ts (1/10)
|
||||
Skipped: before start range (5)
|
||||
⏭️ test/user.test.ts (2/10)
|
||||
Skipped: before start range (5)
|
||||
▶️ test/api.test.ts (5/10)
|
||||
Runtime: node.js
|
||||
✅ api endpoints work (145ms)
|
||||
```
|
||||
|
||||
### Performance Analysis
|
||||
|
||||
@ -328,8 +815,78 @@ tstest test/ --json > test-results.json
|
||||
tstest test/ --quiet
|
||||
```
|
||||
|
||||
**Advanced CI Example:**
|
||||
```bash
|
||||
# Run tests with comprehensive logging and safety features
|
||||
tstest test/ \
|
||||
--timeout 300 \
|
||||
--logfile \
|
||||
--json > test-results.json
|
||||
|
||||
# Run specific test chunks in parallel CI jobs
|
||||
tstest test/ --startFrom 1 --stopAt 10 # Job 1
|
||||
tstest test/ --startFrom 11 --stopAt 20 # Job 2
|
||||
tstest test/ --startFrom 21 # Job 3
|
||||
```
|
||||
|
||||
### Debugging Failed Tests
|
||||
|
||||
When tests fail, use the enhanced logging features:
|
||||
|
||||
```bash
|
||||
# Run with logging to capture detailed output
|
||||
tstest test/ --logfile --verbose
|
||||
|
||||
# Check error logs
|
||||
ls .nogit/testlogs/00err/
|
||||
|
||||
# Review diffs for flaky tests
|
||||
cat .nogit/testlogs/00diff/test__api__endpoints.log
|
||||
|
||||
# Re-run specific failed tests
|
||||
tstest test/api/endpoints.test.ts --verbose --timeout 60
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.11.0
|
||||
- 👀 Added Watch Mode with `--watch`/`-w` flag for automatic test re-runs
|
||||
- 📊 Implemented real-time test progress updates with event streaming
|
||||
- 🎨 Added visual diffs for failed assertions with side-by-side comparison
|
||||
- 🔄 Enhanced event-based test lifecycle reporting
|
||||
- ⚙️ Added test configuration system with `.tstest.json` files
|
||||
- 🚀 Implemented Protocol V2 with Unicode delimiters for better TAP parsing
|
||||
- 🐛 Fixed `tap.todo()` to create proper test objects
|
||||
- 🐛 Fixed `tap.skip.test()` to correctly create and count test objects
|
||||
- 🐛 Fixed `tap.only.test()` implementation with `--only` flag support
|
||||
- 📁 Added settings inheritance for cascading test configuration
|
||||
- ⏱️ Added debouncing for file change events in watch mode
|
||||
|
||||
### Version 1.10.0
|
||||
- ⏱️ Added `--timeout <seconds>` option for test file timeout protection
|
||||
- 🎯 Added `--startFrom <n>` and `--stopAt <n>` options for test file range control
|
||||
- 📁 Enhanced `--logfile` with intelligent log organization:
|
||||
- Previous logs moved to `previous/` folder
|
||||
- Failed tests copied to `00err/` folder
|
||||
- Changed tests create diff reports in `00diff/` folder
|
||||
- 🔍 Improved test discovery to show skipped files with clear reasons
|
||||
- 🐛 Fixed TypeScript compilation warnings and unused variables
|
||||
- 📊 Test summaries now include skipped file counts
|
||||
|
||||
### Version 1.9.2
|
||||
- 🐛 Fixed test timing display issue (removed duplicate timing in output)
|
||||
- 📝 Improved internal protocol design documentation
|
||||
- 🔧 Added protocol v2 utilities for future improvements
|
||||
|
||||
### Version 1.9.1
|
||||
- 🐛 Fixed log file naming to preserve directory structure
|
||||
- 📁 Log files now prevent collisions: `test__dir__file.log`
|
||||
|
||||
### Version 1.9.0
|
||||
- 📚 Comprehensive documentation update
|
||||
- 🏗️ Embedded tapbundle for better integration
|
||||
- 🌐 Full browser compatibility
|
||||
|
||||
### Version 1.8.0
|
||||
- 📦 Embedded tapbundle directly into tstest project
|
||||
- 🌐 Made tapbundle fully browser-compatible
|
||||
@ -342,13 +899,21 @@ tstest test/ --quiet
|
||||
- 📊 Enhanced TAP parser for better test reporting
|
||||
- 🐛 Fixed glob pattern handling in shell scripts
|
||||
|
||||
## Contribution
|
||||
## License and Legal Information
|
||||
|
||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license.md) file within this repository.
|
||||
|
||||
## License
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
> MIT licensed | **©** [Lossless GmbH](https://lossless.gmbh)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
### Trademarks
|
||||
|
||||
[](https://maintainedby.lossless.com)
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
361
readme.plan.md
361
readme.plan.md
@ -2,61 +2,115 @@
|
||||
|
||||
!! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !!
|
||||
|
||||
## 1. Enhanced Communication Between tapbundle and tstest
|
||||
## Improved Internal Protocol (NEW - Critical) ✅ COMPLETED
|
||||
|
||||
### 1.1 Real-time Test Progress API
|
||||
- Create a bidirectional communication channel between tapbundle and tstest
|
||||
- Emit events for test lifecycle stages (start, progress, completion)
|
||||
- Allow tstest to subscribe to tapbundle events for better progress reporting
|
||||
- Implement a standardized message format for test metadata
|
||||
### Current Issues ✅ RESOLVED
|
||||
- ✅ TAP protocol uses `#` for metadata which conflicts with test descriptions containing `#`
|
||||
- ✅ Fragile regex parsing that breaks with special characters
|
||||
- ✅ Limited extensibility for new metadata types
|
||||
|
||||
### 1.2 Rich Error Reporting
|
||||
- Pass structured error objects from tapbundle to tstest
|
||||
- Include stack traces, code snippets, and contextual information
|
||||
- Support for error categorization (assertion failures, timeouts, uncaught exceptions)
|
||||
- Visual diff output for failed assertions
|
||||
### Proposed Solution: Protocol V2 ✅ IMPLEMENTED
|
||||
- ✅ Use Unicode delimiters `⟦TSTEST:META:{}⟧` that won't appear in test names
|
||||
- ✅ Structured JSON metadata format
|
||||
- ✅ Separate protocol blocks for complex data (errors, snapshots)
|
||||
- ✅ Complete replacement of v1 (no backwards compatibility needed)
|
||||
|
||||
### Implementation ✅ COMPLETED
|
||||
- ✅ Phase 1: Create protocol v2 implementation in ts_tapbundle_protocol
|
||||
- ✅ Phase 2: Replace all v1 code in both tstest and tapbundle with v2
|
||||
- ✅ Phase 3: Delete all v1 parsing and generation code
|
||||
|
||||
#### ts_tapbundle_protocol Directory
|
||||
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
|
||||
- **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:
|
||||
- ✅ `ProtocolEmitter`: For generating protocol v2 messages (used by tapbundle)
|
||||
- ✅ `ProtocolParser`: For parsing protocol v2 messages (used by tstest)
|
||||
- ✅ `ProtocolMessage`: Base classes for different message types
|
||||
- ✅ `ProtocolTypes`: TypeScript interfaces and types for protocol structures
|
||||
- **Pure TypeScript**: Only browser-compatible APIs and pure TypeScript/JavaScript code
|
||||
- **Build Integration**:
|
||||
- Compiled by `pnpm build` (via tsbuild) to `dist_ts_tapbundle_protocol/`
|
||||
- Build order defined in tspublish.json files
|
||||
- Imported by ts and ts_tapbundle modules from the compiled dist directory
|
||||
|
||||
See `readme.protocol.md` for detailed specification.
|
||||
|
||||
## Test Configuration System (NEW)
|
||||
|
||||
### Global Test Configuration via 00init.ts
|
||||
- **Discovery**: Check for `test/00init.ts` before running tests
|
||||
- **Execution**: Import and execute before any test files if found
|
||||
- **Purpose**: Define project-wide default test settings
|
||||
|
||||
### tap.settings() API
|
||||
```typescript
|
||||
interface TapSettings {
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Inheritance
|
||||
- Global (00init.ts) → File level → Test level
|
||||
- More specific settings override less specific ones
|
||||
- Arrays/objects are merged, primitives are replaced
|
||||
|
||||
### Implementation Phases
|
||||
1. **Core Infrastructure**: Settings storage and merge logic
|
||||
2. **Discovery**: 00init.ts loading mechanism
|
||||
3. **Application**: Apply settings to test execution
|
||||
4. **Advanced**: Parallel execution and snapshot configuration
|
||||
|
||||
## 1. Enhanced Communication Between tapbundle and tstest ✅ COMPLETED
|
||||
|
||||
### 1.1 Real-time Test Progress API ✅ COMPLETED
|
||||
- ✅ Create a bidirectional communication channel between tapbundle and tstest
|
||||
- ✅ Emit events for test lifecycle stages (start, progress, completion)
|
||||
- ✅ Allow tstest to subscribe to tapbundle events for better progress reporting
|
||||
- ✅ Implement a standardized message format for test metadata
|
||||
|
||||
### 1.2 Rich Error Reporting ✅ COMPLETED
|
||||
- ✅ Pass structured error objects from tapbundle to tstest
|
||||
- ✅ Include stack traces, code snippets, and contextual information
|
||||
- ✅ Support for error categorization (assertion failures, timeouts, uncaught exceptions)
|
||||
- ✅ Visual diff output for failed assertions
|
||||
|
||||
## 2. Enhanced toolsArg Functionality
|
||||
|
||||
### 2.1 Test Flow Control ✅
|
||||
```typescript
|
||||
tap.test('conditional test', async (toolsArg) => {
|
||||
const result = await someOperation();
|
||||
|
||||
// Skip the rest of the test
|
||||
if (!result) {
|
||||
return toolsArg.skip('Precondition not met');
|
||||
}
|
||||
|
||||
// Conditional skipping
|
||||
await toolsArg.skipIf(condition, 'Reason for skipping');
|
||||
|
||||
// Mark test as todo
|
||||
await toolsArg.todo('Not implemented yet');
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 Test Metadata and Configuration ✅
|
||||
```typescript
|
||||
// Fluent syntax ✅
|
||||
tap.tags('slow', 'integration')
|
||||
.priority('high')
|
||||
.timeout(5000)
|
||||
.retry(3)
|
||||
.test('configurable test', async (toolsArg) => {
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 Test Data and Context Sharing ✅
|
||||
### 2.3 Test Data and Context Sharing (Partial)
|
||||
```typescript
|
||||
tap.test('data-driven test', async (toolsArg) => {
|
||||
// Access shared context ✅
|
||||
const sharedData = toolsArg.context.get('sharedData');
|
||||
|
||||
// Set data for other tests ✅
|
||||
toolsArg.context.set('resultData', computedValue);
|
||||
|
||||
// Parameterized test data (not yet implemented)
|
||||
const testData = toolsArg.data<TestInput>();
|
||||
expect(processData(testData)).toEqual(expected);
|
||||
@ -65,32 +119,7 @@ tap.test('data-driven test', async (toolsArg) => {
|
||||
|
||||
## 3. Nested Tests and Test Suites
|
||||
|
||||
### 3.1 Test Grouping with describe() ✅
|
||||
```typescript
|
||||
tap.describe('User Authentication', () => {
|
||||
tap.beforeEach(async (toolsArg) => {
|
||||
// Setup for each test in this suite
|
||||
await toolsArg.context.set('db', await createTestDatabase());
|
||||
});
|
||||
|
||||
tap.afterEach(async (toolsArg) => {
|
||||
// Cleanup after each test
|
||||
await toolsArg.context.get('db').cleanup();
|
||||
});
|
||||
|
||||
tap.test('should login with valid credentials', async (toolsArg) => {
|
||||
// Test implementation
|
||||
});
|
||||
|
||||
tap.describe('Password Reset', () => {
|
||||
tap.test('should send reset email', async (toolsArg) => {
|
||||
// Nested test
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3.2 Hierarchical Test Organization
|
||||
### 3.2 Hierarchical Test Organization (Not yet implemented)
|
||||
- Support for multiple levels of nesting
|
||||
- Inherited context and configuration from parent suites
|
||||
- Aggregated reporting for test suites
|
||||
@ -98,15 +127,7 @@ tap.describe('User Authentication', () => {
|
||||
|
||||
## 4. Advanced Test Features
|
||||
|
||||
### 4.1 Snapshot Testing
|
||||
```typescript
|
||||
tap.test('component render', async (toolsArg) => {
|
||||
const output = renderComponent(props);
|
||||
|
||||
// Compare with stored snapshot
|
||||
await toolsArg.matchSnapshot(output, 'component-output');
|
||||
});
|
||||
```
|
||||
### 4.1 Snapshot Testing ✅ (Basic implementation complete)
|
||||
|
||||
### 4.2 Performance Benchmarking
|
||||
```typescript
|
||||
@ -124,42 +145,20 @@ tap.test('performance test', async (toolsArg) => {
|
||||
});
|
||||
```
|
||||
|
||||
### 4.3 Test Fixtures and Factories ✅
|
||||
```typescript
|
||||
tap.test('with fixtures', async (toolsArg) => {
|
||||
// Create test fixtures
|
||||
const user = await toolsArg.fixture('user', { name: 'Test User' });
|
||||
const post = await toolsArg.fixture('post', { author: user });
|
||||
|
||||
// Use factory functions
|
||||
const users = await toolsArg.factory('user').createMany(5);
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Test Execution Improvements
|
||||
|
||||
### 5.1 Parallel Test Execution ✅
|
||||
- Run independent tests concurrently ✅
|
||||
- Configurable concurrency limits (via file naming convention)
|
||||
- Resource pooling for shared resources
|
||||
- Proper isolation between parallel tests ✅
|
||||
|
||||
Implementation:
|
||||
- Tests with `para__<groupNumber>` in filename run in parallel
|
||||
- Different groups run sequentially
|
||||
- Tests without `para__` run serially
|
||||
|
||||
### 5.2 Watch Mode
|
||||
### 5.2 Watch Mode ✅ COMPLETED
|
||||
- Automatically re-run tests on file changes
|
||||
- Intelligent test selection based on changed files
|
||||
- Fast feedback loop for development
|
||||
- Integration with IDE/editor plugins
|
||||
- Debounced file change detection (300ms)
|
||||
- Clear console output between runs
|
||||
- Shows which files triggered re-runs
|
||||
- Graceful exit with Ctrl+C
|
||||
- `--watch-ignore` option for excluding patterns
|
||||
|
||||
### 5.3 Advanced Test Filtering ✅ (partially)
|
||||
### 5.3 Advanced Test Filtering (Partial) ⚠️
|
||||
```typescript
|
||||
// Run tests by tags ✅
|
||||
tstest --tags "unit,fast"
|
||||
|
||||
// Exclude tests by pattern (not yet implemented)
|
||||
tstest --exclude "**/slow/**"
|
||||
|
||||
@ -198,58 +197,52 @@ tstest --changed
|
||||
- Links to documentation
|
||||
- Code examples in error output
|
||||
|
||||
### 7.2 Interactive Mode (Needs Detailed Specification)
|
||||
- REPL for exploring test failures
|
||||
- Need to define: How to enter interactive mode? When tests fail?
|
||||
- What commands/features should be available in the REPL?
|
||||
- Debugging integration
|
||||
- Node.js inspector protocol integration?
|
||||
- Breakpoint support?
|
||||
- Step-through test execution
|
||||
- Pause between tests?
|
||||
- Step into/over/out functionality?
|
||||
- Interactive test data manipulation
|
||||
- Modify test inputs on the fly?
|
||||
- Inspect intermediate values?
|
||||
|
||||
### 7.3 ~~VS Code Extension~~ (Scratched)
|
||||
- ~~Test explorer integration~~
|
||||
- ~~Inline test results~~
|
||||
- ~~CodeLens for running individual tests~~
|
||||
- ~~Debugging support~~
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Enhancements (Priority: High) ✅
|
||||
1. Implement enhanced toolsArg methods (skip, skipIf, timeout, retry) ✅
|
||||
2. Add basic test grouping with describe() ✅
|
||||
3. Improve error reporting between tapbundle and tstest ✅
|
||||
### Phase 1: Improved Internal Protocol (Priority: Critical) ✅ COMPLETED
|
||||
1. ✅ Create ts_tapbundle_protocol directory with isomorphic protocol v2 implementation
|
||||
- ✅ Implement ProtocolEmitter class for message generation
|
||||
- ✅ Implement ProtocolParser class for message parsing
|
||||
- ✅ Define ProtocolMessage types and interfaces
|
||||
- ✅ Ensure all code is browser and Node.js compatible
|
||||
- ✅ Add tspublish.json to configure build order
|
||||
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
|
||||
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
|
||||
6. ✅ Delete all v1 TAP generation code from tapbundle
|
||||
7. ✅ Test with real-world test suites containing special characters
|
||||
|
||||
### Phase 2: Advanced Features (Priority: Medium)
|
||||
1. Implement nested test suites ✅ (basic describe support)
|
||||
2. Add snapshot testing ✅
|
||||
3. Create test fixture system ✅
|
||||
4. Implement parallel test execution ✅
|
||||
### Phase 2: Test Configuration System (Priority: High) ✅ COMPLETED
|
||||
1. ✅ Implement tap.settings() API with TypeScript interfaces
|
||||
2. ✅ Add 00init.ts discovery and loading mechanism
|
||||
3. ✅ Implement settings inheritance and merge logic
|
||||
4. ✅ Apply settings to test execution (timeouts, retries, etc.)
|
||||
|
||||
### Phase 3: Developer Experience (Priority: Medium)
|
||||
### Phase 3: Enhanced Communication (Priority: High) ✅ COMPLETED
|
||||
1. ✅ Build on Protocol V2 for richer communication
|
||||
2. ✅ Implement real-time test progress API
|
||||
3. ✅ Add structured error reporting with diffs and traces
|
||||
|
||||
### Phase 4: Developer Experience (Priority: Medium) ❌ NOT STARTED
|
||||
1. Add watch mode
|
||||
2. Implement custom reporters
|
||||
3. ~~Create VS Code extension~~ (Scratched)
|
||||
4. Add interactive debugging (Needs detailed spec first)
|
||||
3. Complete advanced test filtering options
|
||||
4. Add performance benchmarking API
|
||||
|
||||
### Phase 4: Analytics and Performance (Priority: Low)
|
||||
### Phase 5: Analytics and Performance (Priority: Low) ❌ NOT STARTED
|
||||
1. Build test analytics dashboard
|
||||
2. Add performance benchmarking
|
||||
3. Implement coverage integration
|
||||
4. Create trend analysis tools
|
||||
2. Implement coverage integration
|
||||
3. Create trend analysis tools
|
||||
4. Add test impact analysis
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### API Design Principles
|
||||
- Maintain backward compatibility
|
||||
- Clean, modern API design without legacy constraints
|
||||
- Progressive enhancement approach
|
||||
- Opt-in features to avoid breaking changes
|
||||
- Clear migration paths for new features
|
||||
- Well-documented features and APIs
|
||||
- Clear, simple interfaces
|
||||
|
||||
### Performance Goals
|
||||
- Minimal overhead for test execution
|
||||
@ -261,4 +254,68 @@ tstest --changed
|
||||
- Clean interfaces between tstest and tapbundle
|
||||
- Extensible plugin architecture
|
||||
- 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
|
||||
|
||||
### Recently Fixed Issues ✅
|
||||
- **tap.todo()**: Now fully implemented with test object creation
|
||||
- **tap.skip.test()**: Now creates test objects and maintains accurate test count
|
||||
- **tap.only.test()**: Works correctly - when .only tests exist, only those run
|
||||
|
||||
### Remaining Minor Issues
|
||||
- **Protocol Output**: Some protocol messages still appear in console output
|
||||
|
||||
### Next Recommended Steps
|
||||
1. Add Watch Mode (Phase 4) - high developer value for fast feedback
|
||||
2. Implement Custom Reporters - important for CI/CD integration
|
||||
3. Implement performance benchmarking API
|
||||
4. Add better error messages with suggestions
|
287
readme.protocol.md
Normal file
287
readme.protocol.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Improved Internal Protocol Design
|
||||
|
||||
## Current Issues with TAP Protocol
|
||||
|
||||
1. **Delimiter Conflict**: Using `#` for metadata conflicts with test descriptions containing `#`
|
||||
2. **Ambiguous Parsing**: No clear boundary between test name and metadata
|
||||
3. **Limited Extensibility**: Adding new metadata requires regex changes
|
||||
4. **Mixed Concerns**: Protocol data mixed with human-readable output
|
||||
|
||||
## Proposed Internal Protocol v2
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Clear Separation**: Protocol data must be unambiguously separated from user content
|
||||
2. **Extensibility**: Easy to add new metadata without breaking parsers
|
||||
3. **Backwards Compatible**: Can coexist with standard TAP for gradual migration
|
||||
4. **Machine Readable**: Structured format for reliable parsing
|
||||
5. **Human Friendly**: Still readable in raw form
|
||||
|
||||
### Protocol Options
|
||||
|
||||
#### Option 1: Special Delimiters
|
||||
```
|
||||
ok 1 - test description ::TSTEST:: {"time":123,"retry":0}
|
||||
not ok 2 - another test ::TSTEST:: {"time":45,"error":"timeout"}
|
||||
ok 3 - skipped test ::TSTEST:: {"time":0,"skip":"not ready"}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Simple to implement
|
||||
- Backwards compatible with TAP parsers (they ignore the suffix)
|
||||
- Easy to parse with split()
|
||||
|
||||
**Cons**:
|
||||
- Still could conflict if test name contains `::TSTEST::`
|
||||
- Not standard TAP
|
||||
|
||||
#### Option 2: Separate Metadata Lines
|
||||
```
|
||||
ok 1 - test description
|
||||
::METADATA:: {"test":1,"time":123,"retry":0}
|
||||
not ok 2 - another test
|
||||
::METADATA:: {"test":2,"time":45,"error":"timeout"}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Complete separation of concerns
|
||||
- No chance of conflicts
|
||||
- Can include arbitrary metadata
|
||||
|
||||
**Cons**:
|
||||
- Requires correlation between lines
|
||||
- More complex parsing
|
||||
|
||||
#### Option 3: YAML Blocks (TAP 13 Compatible)
|
||||
```
|
||||
ok 1 - test description
|
||||
---
|
||||
time: 123
|
||||
retry: 0
|
||||
...
|
||||
not ok 2 - another test
|
||||
---
|
||||
time: 45
|
||||
error: timeout
|
||||
stack: |
|
||||
Error: timeout
|
||||
at Test.run (test.js:10:5)
|
||||
...
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Standard TAP 13 feature
|
||||
- Structured data format
|
||||
- Human readable
|
||||
- Extensible
|
||||
|
||||
**Cons**:
|
||||
- More verbose
|
||||
- YAML parsing overhead
|
||||
|
||||
#### Option 4: Binary Protocol Markers (Recommended)
|
||||
```
|
||||
ok 1 - test description
|
||||
␛[TSTEST:eyJ0aW1lIjoxMjMsInJldHJ5IjowfQ==]␛
|
||||
not ok 2 - another test
|
||||
␛[TSTEST:eyJ0aW1lIjo0NSwiZXJyb3IiOiJ0aW1lb3V0In0=]␛
|
||||
```
|
||||
|
||||
Using ASCII escape character (␛ = \x1B) with base64 encoded JSON.
|
||||
|
||||
**Pros**:
|
||||
- Zero chance of accidental conflicts
|
||||
- Compact
|
||||
- Fast to parse
|
||||
- Invisible in most terminals
|
||||
|
||||
**Cons**:
|
||||
- Not human readable in raw form
|
||||
- Requires base64 encoding/decoding
|
||||
|
||||
### Recommended Implementation: Hybrid Approach
|
||||
|
||||
Use multiple strategies based on context:
|
||||
|
||||
1. **For timing and basic metadata**: Use structured delimiters
|
||||
```
|
||||
ok 1 - test name ⟦time:123,retry:0⟧
|
||||
```
|
||||
|
||||
2. **For complex data (errors, snapshots)**: Use separate protocol lines
|
||||
```
|
||||
ok 1 - test failed
|
||||
⟦TSTEST:ERROR⟧
|
||||
{"message":"Assertion failed","stack":"...","diff":"..."}
|
||||
⟦/TSTEST:ERROR⟧
|
||||
```
|
||||
|
||||
3. **For human-readable output**: Keep standard TAP comments
|
||||
```
|
||||
# Test suite: User Authentication
|
||||
ok 1 - should login
|
||||
```
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### Phase 1: Parser Enhancement
|
||||
1. Add new protocol parser alongside existing TAP parser
|
||||
2. Support both old and new formats during transition
|
||||
3. Add protocol version negotiation
|
||||
|
||||
#### Phase 2: Metadata Structure
|
||||
```typescript
|
||||
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>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 3: Protocol Messages
|
||||
|
||||
##### Success Message
|
||||
```
|
||||
ok 1 - user authentication works
|
||||
⟦TSTEST:META:{"time":123,"tags":["auth","unit"]}⟧
|
||||
```
|
||||
|
||||
##### Failure Message
|
||||
```
|
||||
not ok 2 - login fails with invalid password
|
||||
⟦TSTEST:META:{"time":45,"retry":1,"maxRetries":3}⟧
|
||||
⟦TSTEST:ERROR⟧
|
||||
{
|
||||
"message": "Expected 401 but got 500",
|
||||
"stack": "Error: Expected 401 but got 500\n at Test.run (auth.test.ts:25:10)",
|
||||
"actual": 500,
|
||||
"expected": 401
|
||||
}
|
||||
⟦/TSTEST:ERROR⟧
|
||||
```
|
||||
|
||||
##### Skip Message
|
||||
```
|
||||
ok 3 - database integration test ⟦TSTEST:SKIP:No database connection⟧
|
||||
```
|
||||
|
||||
##### Snapshot Communication
|
||||
```
|
||||
⟦TSTEST:SNAPSHOT:user-profile⟧
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"roles": ["user", "admin"]
|
||||
}
|
||||
⟦/TSTEST:SNAPSHOT⟧
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Version Detection**: First line indicates protocol version
|
||||
```
|
||||
⟦TSTEST:PROTOCOL:2.0⟧
|
||||
TAP version 13
|
||||
```
|
||||
|
||||
2. **Gradual Rollout**:
|
||||
- v1.10: Add protocol v2 parser, keep v1 generator
|
||||
- v1.11: Generate v2 by default, v1 with --legacy flag
|
||||
- v2.0: Remove v1 support
|
||||
|
||||
3. **Feature Flags**:
|
||||
```typescript
|
||||
tap.settings({
|
||||
protocol: 'v2', // or 'v1', 'auto'
|
||||
protocolFeatures: {
|
||||
structuredErrors: true,
|
||||
enhancedTiming: true,
|
||||
binaryMarkers: false
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Benefits of New Protocol
|
||||
|
||||
1. **Reliability**: No more regex fragility or description conflicts
|
||||
2. **Performance**: Faster parsing with clear boundaries
|
||||
3. **Extensibility**: Easy to add new metadata fields
|
||||
4. **Debugging**: Rich error information with stack traces and diffs
|
||||
5. **Integration**: Better IDE and CI/CD tool integration
|
||||
6. **Forward Compatible**: Room for future enhancements
|
||||
|
||||
### Example Parser Implementation
|
||||
|
||||
```typescript
|
||||
class ProtocolV2Parser {
|
||||
private readonly MARKER_START = '⟦TSTEST:';
|
||||
private readonly MARKER_END = '⟧';
|
||||
|
||||
parseMetadata(line: string): TestMetadata | null {
|
||||
const start = line.lastIndexOf(this.MARKER_START);
|
||||
if (start === -1) return null;
|
||||
|
||||
const end = line.indexOf(this.MARKER_END, start);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = line.substring(start + this.MARKER_START.length, end);
|
||||
const [type, data] = content.split(':', 2);
|
||||
|
||||
switch (type) {
|
||||
case 'META':
|
||||
return JSON.parse(data);
|
||||
case 'SKIP':
|
||||
return { skip: data };
|
||||
case 'TODO':
|
||||
return { todo: data };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
parseTestLine(line: string): ParsedTest {
|
||||
// First extract any metadata
|
||||
const metadata = this.parseMetadata(line);
|
||||
|
||||
// Then parse the TAP part (without metadata)
|
||||
const cleanLine = this.removeMetadata(line);
|
||||
const tapResult = this.parseTAP(cleanLine);
|
||||
|
||||
return { ...tapResult, metadata };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Implement proof of concept with basic metadata support
|
||||
2. Test with real-world test suites for edge cases
|
||||
3. Benchmark parsing performance
|
||||
4. Get feedback from users
|
||||
5. Finalize protocol specification
|
||||
6. Implement in both tapbundle and tstest
|
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();
|
8
test/glob-test/another.spec.ts
Normal file
8
test/glob-test/another.spec.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { tap } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('spec file test', async () => {
|
||||
console.log('This is a .spec.ts file that should be found by glob');
|
||||
return true;
|
||||
});
|
||||
|
||||
tap.start();
|
8
test/glob-test/nested/test.nested-glob.ts
Normal file
8
test/glob-test/nested/test.nested-glob.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { tap } from '../../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('nested glob pattern test', async () => {
|
||||
console.log('This test file is in a nested directory');
|
||||
return true;
|
||||
});
|
||||
|
||||
tap.start();
|
8
test/glob-test/test.glob-test.ts
Normal file
8
test/glob-test/test.glob-test.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { tap } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('glob pattern test', async () => {
|
||||
console.log('This test file should be found by glob patterns');
|
||||
return true;
|
||||
});
|
||||
|
||||
tap.start();
|
17
test/watch-demo/test.demo.ts
Normal file
17
test/watch-demo/test.demo.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
|
||||
// This test file demonstrates watch mode
|
||||
// Try modifying this file while running: tstest test/watch-demo --watch
|
||||
|
||||
let counter = 1;
|
||||
|
||||
tap.test('demo test that changes', async () => {
|
||||
expect(counter).toEqual(1);
|
||||
console.log(`Test run at: ${new Date().toISOString()}`);
|
||||
});
|
||||
|
||||
tap.test('another test', async () => {
|
||||
expect('hello').toEqual('hello');
|
||||
});
|
||||
|
||||
tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tstest',
|
||||
version: '1.9.2',
|
||||
version: '2.2.1',
|
||||
description: 'a test utility to run tests that match test/**/*.ts'
|
||||
}
|
||||
|
93
ts/index.ts
93
ts/index.ts
@ -13,6 +13,11 @@ export const runCli = async () => {
|
||||
const logOptions: LogOptions = {};
|
||||
let testPath: string | null = null;
|
||||
let tags: string[] = [];
|
||||
let startFromFile: number | null = null;
|
||||
let stopAtFile: number | null = null;
|
||||
let timeoutSeconds: number | null = null;
|
||||
let watchMode: boolean = false;
|
||||
let watchIgnorePatterns: string[] = [];
|
||||
|
||||
// Parse options
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@ -42,6 +47,57 @@ export const runCli = async () => {
|
||||
tags = args[++i].split(',');
|
||||
}
|
||||
break;
|
||||
case '--startFrom':
|
||||
if (i + 1 < args.length) {
|
||||
const value = parseInt(args[++i], 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
console.error('Error: --startFrom must be a positive integer');
|
||||
process.exit(1);
|
||||
}
|
||||
startFromFile = value;
|
||||
} else {
|
||||
console.error('Error: --startFrom requires a number argument');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
case '--stopAt':
|
||||
if (i + 1 < args.length) {
|
||||
const value = parseInt(args[++i], 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
console.error('Error: --stopAt must be a positive integer');
|
||||
process.exit(1);
|
||||
}
|
||||
stopAtFile = value;
|
||||
} else {
|
||||
console.error('Error: --stopAt requires a number argument');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
case '--timeout':
|
||||
if (i + 1 < args.length) {
|
||||
const value = parseInt(args[++i], 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
console.error('Error: --timeout must be a positive integer (seconds)');
|
||||
process.exit(1);
|
||||
}
|
||||
timeoutSeconds = value;
|
||||
} else {
|
||||
console.error('Error: --timeout requires a number argument (seconds)');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
case '--watch':
|
||||
case '-w':
|
||||
watchMode = true;
|
||||
break;
|
||||
case '--watch-ignore':
|
||||
if (i + 1 < args.length) {
|
||||
watchIgnorePatterns = args[++i].split(',');
|
||||
} else {
|
||||
console.error('Error: --watch-ignore requires a comma-separated list of patterns');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!arg.startsWith('-')) {
|
||||
testPath = arg;
|
||||
@ -49,16 +105,27 @@ export const runCli = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate test file range options
|
||||
if (startFromFile !== null && stopAtFile !== null && startFromFile > stopAtFile) {
|
||||
console.error('Error: --startFrom cannot be greater than --stopAt');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!testPath) {
|
||||
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
|
||||
console.error('\nUsage: tstest <path> [options]');
|
||||
console.error('\nOptions:');
|
||||
console.error(' --quiet, -q Minimal output');
|
||||
console.error(' --verbose, -v Verbose output');
|
||||
console.error(' --no-color Disable colored output');
|
||||
console.error(' --json Output results as JSON');
|
||||
console.error(' --logfile Write logs to .nogit/testlogs/[testfile].log');
|
||||
console.error(' --tags Run only tests with specified tags (comma-separated)');
|
||||
console.error(' --quiet, -q Minimal output');
|
||||
console.error(' --verbose, -v Verbose output');
|
||||
console.error(' --no-color Disable colored output');
|
||||
console.error(' --json Output results as JSON');
|
||||
console.error(' --logfile Write logs to .nogit/testlogs/[testfile].log');
|
||||
console.error(' --tags <tags> Run only tests with specified tags (comma-separated)');
|
||||
console.error(' --startFrom <n> Start running from test file number n');
|
||||
console.error(' --stopAt <n> Stop running at test file number n');
|
||||
console.error(' --timeout <s> Timeout test files after s seconds');
|
||||
console.error(' --watch, -w Watch for file changes and re-run tests');
|
||||
console.error(' --watch-ignore Patterns to ignore in watch mode (comma-separated)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -73,6 +140,16 @@ export const runCli = async () => {
|
||||
executionMode = TestExecutionMode.DIRECTORY;
|
||||
}
|
||||
|
||||
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags);
|
||||
await tsTestInstance.run();
|
||||
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile, timeoutSeconds);
|
||||
|
||||
if (watchMode) {
|
||||
await tsTestInstance.runWatch(watchIgnorePatterns);
|
||||
} else {
|
||||
await tsTestInstance.run();
|
||||
}
|
||||
};
|
||||
|
||||
// Execute CLI when this file is run directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runCli();
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
"order": 4
|
||||
}
|
@ -10,6 +10,7 @@ import { TsTestLogger } from './tstest.logging.js';
|
||||
|
||||
export class TapCombinator {
|
||||
tapParserStore: TapParser[] = [];
|
||||
skippedFiles: string[] = [];
|
||||
private logger: TsTestLogger;
|
||||
|
||||
constructor(logger: TsTestLogger) {
|
||||
@ -19,10 +20,14 @@ export class TapCombinator {
|
||||
addTapParser(tapParserArg: TapParser) {
|
||||
this.tapParserStore.push(tapParserArg);
|
||||
}
|
||||
|
||||
addSkippedFile(filename: string) {
|
||||
this.skippedFiles.push(filename);
|
||||
}
|
||||
|
||||
evaluate() {
|
||||
// Call the logger's summary method
|
||||
this.logger.summary();
|
||||
// Call the logger's summary method with skipped files
|
||||
this.logger.summary(this.skippedFiles);
|
||||
|
||||
// Check for failures
|
||||
let failGlobal = false;
|
||||
|
@ -8,28 +8,57 @@ import * as plugins from './tstest.plugins.js';
|
||||
import { TapTestResult } from './tstest.classes.tap.testresult.js';
|
||||
import * as logPrefixes from './tstest.logprefixes.js';
|
||||
import { TsTestLogger } from './tstest.logging.js';
|
||||
import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
import type { IProtocolMessage, ITestResult, IPlanLine, IErrorBlock, ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
|
||||
export class TapParser {
|
||||
testStore: TapTestResult[] = [];
|
||||
|
||||
expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/;
|
||||
expectedTests: number;
|
||||
receivedTests: number;
|
||||
expectedTests: number = 0;
|
||||
receivedTests: number = 0;
|
||||
|
||||
testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*)(\s#\s(.*))?$/;
|
||||
activeTapTestResult: TapTestResult;
|
||||
collectingErrorDetails: boolean = false;
|
||||
currentTestError: string[] = [];
|
||||
|
||||
pretaskRegex = /^::__PRETASK:(.*)$/;
|
||||
|
||||
private logger: TsTestLogger;
|
||||
private protocolParser: ProtocolParser;
|
||||
private protocolVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* the constructor for TapParser
|
||||
*/
|
||||
constructor(public fileName: string, logger?: TsTestLogger) {
|
||||
this.logger = logger;
|
||||
this.protocolParser = new ProtocolParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle test file timeout
|
||||
*/
|
||||
public handleTimeout(timeoutSeconds: number) {
|
||||
// If no tests have been defined yet, set expected to 1
|
||||
if (this.expectedTests === 0) {
|
||||
this.expectedTests = 1;
|
||||
}
|
||||
|
||||
// Create a fake failing test result for timeout
|
||||
this._getNewTapTestResult();
|
||||
this.activeTapTestResult.testOk = false;
|
||||
this.activeTapTestResult.testSettled = true;
|
||||
this.testStore.push(this.activeTapTestResult);
|
||||
|
||||
// Log the timeout error
|
||||
if (this.logger) {
|
||||
// First log the test result
|
||||
this.logger.testResult(
|
||||
`Test file timeout`,
|
||||
false,
|
||||
timeoutSeconds * 1000,
|
||||
`Error: Test file exceeded timeout of ${timeoutSeconds} seconds`
|
||||
);
|
||||
this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`);
|
||||
}
|
||||
|
||||
// Don't call evaluateFinalResult here, let the caller handle it
|
||||
}
|
||||
|
||||
private _getNewTapTestResult() {
|
||||
@ -45,142 +74,299 @@ export class TapParser {
|
||||
logLineArray.pop();
|
||||
}
|
||||
|
||||
// lets parse the log information
|
||||
// Process each line through the protocol parser
|
||||
for (const logLine of logLineArray) {
|
||||
let logLineIsTapProtocol = false;
|
||||
if (!this.expectedTests && this.expectedTestsRegex.test(logLine)) {
|
||||
logLineIsTapProtocol = true;
|
||||
const regexResult = this.expectedTestsRegex.exec(logLine);
|
||||
this.expectedTests = parseInt(regexResult[2]);
|
||||
if (this.logger) {
|
||||
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
|
||||
const messages = this.protocolParser.parseLine(logLine);
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Handle protocol messages
|
||||
for (const message of messages) {
|
||||
this._handleProtocolMessage(message, logLine);
|
||||
}
|
||||
|
||||
// initiating first TapResult
|
||||
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]);
|
||||
const testOk = (() => {
|
||||
if (regexResult[1] === 'ok') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const testSubject = regexResult[3];
|
||||
const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason"
|
||||
|
||||
let testDuration = 0;
|
||||
let isSkipped = false;
|
||||
let isTodo = false;
|
||||
|
||||
if (testMetadata) {
|
||||
const timeMatch = testMetadata.match(/time=(\d+)ms/);
|
||||
const skipMatch = testMetadata.match(/SKIP\s*(.*)/);
|
||||
const todoMatch = testMetadata.match(/TODO\s*(.*)/);
|
||||
|
||||
if (timeMatch) {
|
||||
testDuration = parseInt(timeMatch[1]);
|
||||
} else if (skipMatch) {
|
||||
isSkipped = true;
|
||||
} else if (todoMatch) {
|
||||
isTodo = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
} else {
|
||||
// Not a protocol message, handle as console output
|
||||
if (this.activeTapTestResult) {
|
||||
this.activeTapTestResult.addLogLine(logLine);
|
||||
}
|
||||
|
||||
// Check for snapshot communication
|
||||
// Check for snapshot communication (legacy)
|
||||
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||
if (snapshotMatch) {
|
||||
const base64Data = snapshotMatch[1];
|
||||
try {
|
||||
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
|
||||
this.handleSnapshot(snapshotData);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (this.logger) {
|
||||
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if we're collecting error details
|
||||
if (this.collectingErrorDetails) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
} else if (this.logger) {
|
||||
// This is console output from the test file
|
||||
this.logger.testConsoleOutput(logLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.activeTapTestResult && this.activeTapTestResult.testSettled) {
|
||||
// Ensure any pending error is shown before settling the test
|
||||
if (this.collectingErrorDetails && this.currentTestError.length > 0) {
|
||||
const errorMessage = this.currentTestError.join('\n');
|
||||
private _handleProtocolMessage(message: IProtocolMessage, originalLine: string) {
|
||||
switch (message.type) {
|
||||
case 'protocol':
|
||||
this.protocolVersion = message.content.version;
|
||||
if (this.logger) {
|
||||
this.logger.tapOutput(`Protocol version: ${this.protocolVersion}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'version':
|
||||
// TAP version, we can ignore this
|
||||
break;
|
||||
|
||||
case 'plan':
|
||||
const plan = message.content as IPlanLine;
|
||||
this.expectedTests = plan.end - plan.start + 1;
|
||||
if (plan.skipAll) {
|
||||
if (this.logger) {
|
||||
this.logger.testErrorDetails(errorMessage);
|
||||
this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`);
|
||||
}
|
||||
this.collectingErrorDetails = false;
|
||||
this.currentTestError = [];
|
||||
} else {
|
||||
if (this.logger) {
|
||||
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
|
||||
}
|
||||
}
|
||||
// Initialize first TapResult
|
||||
this._getNewTapTestResult();
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
const testResult = message.content as ITestResult;
|
||||
|
||||
// Update active test result
|
||||
this.activeTapTestResult.setTestResult(testResult.ok);
|
||||
|
||||
// Extract test duration from metadata
|
||||
let testDuration = 0;
|
||||
if (testResult.metadata?.time) {
|
||||
testDuration = testResult.metadata.time;
|
||||
}
|
||||
|
||||
// Log test result
|
||||
if (this.logger) {
|
||||
if (testResult.ok) {
|
||||
this.logger.testResult(testResult.description, true, testDuration);
|
||||
} else {
|
||||
this.logger.testResult(testResult.description, false, testDuration);
|
||||
|
||||
// If there's error metadata, show it
|
||||
if (testResult.metadata?.error) {
|
||||
const error = testResult.metadata.error;
|
||||
let errorDetails = error.message;
|
||||
if (error.stack) {
|
||||
errorDetails = error.stack;
|
||||
}
|
||||
this.logger.testErrorDetails(errorDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle directives (skip/todo)
|
||||
if (testResult.directive) {
|
||||
if (this.logger) {
|
||||
if (testResult.directive.type === 'skip') {
|
||||
this.logger.testConsoleOutput(`Test skipped: ${testResult.directive.reason || 'No reason given'}`);
|
||||
} else if (testResult.directive.type === 'todo') {
|
||||
this.logger.testConsoleOutput(`Test todo: ${testResult.directive.reason || 'No reason given'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark test as settled and move to next
|
||||
this.activeTapTestResult.testSettled = true;
|
||||
this.testStore.push(this.activeTapTestResult);
|
||||
this._getNewTapTestResult();
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
if (this.logger) {
|
||||
// Check if it's a pretask comment
|
||||
const pretaskMatch = message.content.match(/^Pretask -> (.+): Success\.$/);
|
||||
if (pretaskMatch) {
|
||||
this.logger.tapOutput(message.content);
|
||||
} else {
|
||||
this.logger.testConsoleOutput(message.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bailout':
|
||||
if (this.logger) {
|
||||
this.logger.error(`Bail out! ${message.content}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
const errorBlock = message.content as IErrorBlock;
|
||||
if (this.logger && errorBlock.error) {
|
||||
let errorDetails = errorBlock.error.message;
|
||||
if (errorBlock.error.stack) {
|
||||
errorDetails = errorBlock.error.stack;
|
||||
}
|
||||
this.logger.testErrorDetails(errorDetails);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
// Handle new protocol snapshot format
|
||||
const snapshot = message.content;
|
||||
this.handleSnapshot({
|
||||
path: snapshot.name,
|
||||
content: typeof snapshot.content === 'string' ? snapshot.content : JSON.stringify(snapshot.content),
|
||||
action: 'compare' // Default action
|
||||
});
|
||||
break;
|
||||
|
||||
case 'event':
|
||||
const event = message.content as ITestEvent;
|
||||
this._handleTestEvent(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTestEvent(event: ITestEvent) {
|
||||
if (!this.logger) return;
|
||||
|
||||
switch (event.eventType) {
|
||||
case 'test:queued':
|
||||
// We can track queued tests if needed
|
||||
break;
|
||||
|
||||
case 'test:started':
|
||||
this.logger.testConsoleOutput(cs(`Test starting: ${event.data.description}`, 'cyan'));
|
||||
if (event.data.retry) {
|
||||
this.logger.testConsoleOutput(cs(` Retry attempt ${event.data.retry}`, 'orange'));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'test:progress':
|
||||
if (event.data.progress !== undefined) {
|
||||
this.logger.testConsoleOutput(cs(` Progress: ${event.data.progress}%`, 'cyan'));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'test:completed':
|
||||
// Test completion is already handled by the test result
|
||||
// This event provides additional timing info if needed
|
||||
break;
|
||||
|
||||
case 'suite:started':
|
||||
this.logger.testConsoleOutput(cs(`\nSuite: ${event.data.suiteName}`, 'blue'));
|
||||
break;
|
||||
|
||||
case 'suite:completed':
|
||||
this.logger.testConsoleOutput(cs(`Suite completed: ${event.data.suiteName}\n`, 'blue'));
|
||||
break;
|
||||
|
||||
case 'hook:started':
|
||||
this.logger.testConsoleOutput(cs(` Hook: ${event.data.hookName}`, 'cyan'));
|
||||
break;
|
||||
|
||||
case 'hook:completed':
|
||||
// Silent unless there's an error
|
||||
if (event.data.error) {
|
||||
this.logger.testConsoleOutput(cs(` Hook failed: ${event.data.hookName}`, 'red'));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assertion:failed':
|
||||
// Enhanced assertion failure with diff
|
||||
if (event.data.error) {
|
||||
this._displayAssertionError(event.data.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _displayAssertionError(error: any) {
|
||||
if (!this.logger) return;
|
||||
|
||||
// Display error message
|
||||
if (error.message) {
|
||||
this.logger.testErrorDetails(error.message);
|
||||
}
|
||||
|
||||
// Display visual diff if available
|
||||
if (error.diff) {
|
||||
this._displayDiff(error.diff, error.expected, error.actual);
|
||||
}
|
||||
}
|
||||
|
||||
private _displayDiff(diff: any, expected: any, actual: any) {
|
||||
if (!this.logger) return;
|
||||
|
||||
this.logger.testConsoleOutput(cs('\n Diff:', 'cyan'));
|
||||
|
||||
switch (diff.type) {
|
||||
case 'string':
|
||||
this._displayStringDiff(diff.changes);
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
this._displayObjectDiff(diff.changes, expected, actual);
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
this._displayArrayDiff(diff.changes, expected, actual);
|
||||
break;
|
||||
|
||||
case 'primitive':
|
||||
this._displayPrimitiveDiff(diff.changes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _displayStringDiff(changes: any[]) {
|
||||
for (const change of changes) {
|
||||
const linePrefix = ` Line ${change.line + 1}: `;
|
||||
if (change.type === 'add') {
|
||||
this.logger.testConsoleOutput(cs(`${linePrefix}+ ${change.content}`, 'green'));
|
||||
} else if (change.type === 'remove') {
|
||||
this.logger.testConsoleOutput(cs(`${linePrefix}- ${change.content}`, 'red'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _displayObjectDiff(changes: any[], expected: any, actual: any) {
|
||||
this.logger.testConsoleOutput(cs(' Expected:', 'red'));
|
||||
this.logger.testConsoleOutput(` ${JSON.stringify(expected, null, 2)}`);
|
||||
this.logger.testConsoleOutput(cs(' Actual:', 'green'));
|
||||
this.logger.testConsoleOutput(` ${JSON.stringify(actual, null, 2)}`);
|
||||
|
||||
this.logger.testConsoleOutput(cs('\n Changes:', 'cyan'));
|
||||
for (const change of changes) {
|
||||
const path = change.path.join('.');
|
||||
if (change.type === 'add') {
|
||||
this.logger.testConsoleOutput(cs(` + ${path}: ${JSON.stringify(change.newValue)}`, 'green'));
|
||||
} else if (change.type === 'remove') {
|
||||
this.logger.testConsoleOutput(cs(` - ${path}: ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||
} else if (change.type === 'modify') {
|
||||
this.logger.testConsoleOutput(cs(` ~ ${path}:`, 'cyan'));
|
||||
this.logger.testConsoleOutput(cs(` - ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||
this.logger.testConsoleOutput(cs(` + ${JSON.stringify(change.newValue)}`, 'green'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _displayArrayDiff(changes: any[], expected: any[], actual: any[]) {
|
||||
this._displayObjectDiff(changes, expected, actual);
|
||||
}
|
||||
|
||||
private _displayPrimitiveDiff(changes: any[]) {
|
||||
const change = changes[0];
|
||||
if (change) {
|
||||
this.logger.testConsoleOutput(cs(` Expected: ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||
this.logger.testConsoleOutput(cs(` Actual: ${JSON.stringify(change.newValue)}`, 'green'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all tests that are not completed
|
||||
@ -305,13 +491,16 @@ export class TapParser {
|
||||
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
|
||||
}
|
||||
}
|
||||
if (!this.expectedTests) {
|
||||
if (!this.expectedTests && this.receivedTests === 0) {
|
||||
if (this.logger) {
|
||||
this.logger.error('No tests were defined. Therefore the testfile failed!');
|
||||
this.logger.testFileEnd(0, 1, 0); // Count as 1 failure
|
||||
}
|
||||
} else if (this.expectedTests !== this.receivedTests) {
|
||||
if (this.logger) {
|
||||
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
|
||||
const errorCount = this.getErrorTests().length || 1; // At least 1 error
|
||||
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0);
|
||||
}
|
||||
} else if (this.getErrorTests().length === 0) {
|
||||
if (this.logger) {
|
||||
@ -325,4 +514,4 @@ export class TapParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import * as plugins from './tstest.plugins.js';
|
||||
import * as paths from './tstest.paths.js';
|
||||
import * as logPrefixes from './tstest.logprefixes.js';
|
||||
|
||||
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||
|
||||
@ -16,6 +15,10 @@ export class TsTest {
|
||||
public executionMode: TestExecutionMode;
|
||||
public logger: TsTestLogger;
|
||||
public filterTags: string[];
|
||||
public startFromFile: number | null;
|
||||
public stopAtFile: number | null;
|
||||
public timeoutSeconds: number | null;
|
||||
private timeoutWarningTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
public smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
@ -26,18 +29,35 @@ export class TsTest {
|
||||
|
||||
public tsbundleInstance = new plugins.tsbundle.TsBundle();
|
||||
|
||||
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = []) {
|
||||
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
|
||||
this.executionMode = executionModeArg;
|
||||
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
||||
this.logger = new TsTestLogger(logOptions);
|
||||
this.filterTags = tags;
|
||||
this.startFromFile = startFromFile;
|
||||
this.stopAtFile = stopAtFile;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
async run() {
|
||||
// Move previous log files if --logfile option is used
|
||||
if (this.logger.options.logFile) {
|
||||
await this.movePreviousLogFiles();
|
||||
}
|
||||
|
||||
// Start timeout warning timer if no timeout was specified
|
||||
if (this.timeoutSeconds === null) {
|
||||
this.timeoutWarningTimer = setTimeout(() => {
|
||||
this.logger.warning('Test is running for more than 1 minute.');
|
||||
this.logger.warning('Consider using --timeout option to set a timeout for test files.');
|
||||
this.logger.warning('Example: tstest test --timeout=300 (for 5 minutes)');
|
||||
}, 60000); // 1 minute
|
||||
}
|
||||
|
||||
const testGroups = await this.testDir.getTestFileGroups();
|
||||
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
||||
|
||||
// Log test discovery
|
||||
// Log test discovery - always show full count
|
||||
this.logger.testDiscovery(
|
||||
allFiles.length,
|
||||
this.testDir.testPath,
|
||||
@ -50,7 +70,7 @@ export class TsTest {
|
||||
// Execute serial tests first
|
||||
for (const fileNameArg of testGroups.serial) {
|
||||
fileIndex++;
|
||||
await this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||
await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||
}
|
||||
|
||||
// Execute parallel groups sequentially
|
||||
@ -64,7 +84,7 @@ export class TsTest {
|
||||
// Run all tests in this group in parallel
|
||||
const parallelPromises = groupFiles.map(async (fileNameArg) => {
|
||||
fileIndex++;
|
||||
return this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||
return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||
});
|
||||
|
||||
await Promise.all(parallelPromises);
|
||||
@ -72,9 +92,104 @@ export class TsTest {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the timeout warning timer if it was set
|
||||
if (this.timeoutWarningTimer) {
|
||||
clearTimeout(this.timeoutWarningTimer);
|
||||
this.timeoutWarningTimer = null;
|
||||
}
|
||||
|
||||
tapCombinator.evaluate();
|
||||
}
|
||||
|
||||
public async runWatch(ignorePatterns: string[] = []) {
|
||||
const smartchokInstance = new plugins.smartchok.Smartchok([this.testDir.cwd]);
|
||||
|
||||
console.clear();
|
||||
this.logger.watchModeStart();
|
||||
|
||||
// Initial run
|
||||
await this.run();
|
||||
|
||||
// Set up file watcher
|
||||
const fileChanges = new Map<string, NodeJS.Timeout>();
|
||||
const debounceTime = 300; // 300ms debounce
|
||||
|
||||
const runTestsAfterChange = async () => {
|
||||
console.clear();
|
||||
const changedFiles = Array.from(fileChanges.keys());
|
||||
fileChanges.clear();
|
||||
|
||||
this.logger.watchModeRerun(changedFiles);
|
||||
await this.run();
|
||||
this.logger.watchModeWaiting();
|
||||
};
|
||||
|
||||
// Start watching before subscribing to events
|
||||
await smartchokInstance.start();
|
||||
|
||||
// Subscribe to file change events
|
||||
const changeObservable = await smartchokInstance.getObservableFor('change');
|
||||
const addObservable = await smartchokInstance.getObservableFor('add');
|
||||
const unlinkObservable = await smartchokInstance.getObservableFor('unlink');
|
||||
|
||||
const handleFileChange = (changedPath: string) => {
|
||||
// Skip if path matches ignore patterns
|
||||
if (ignorePatterns.some(pattern => changedPath.includes(pattern))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout for this file if any
|
||||
if (fileChanges.has(changedPath)) {
|
||||
clearTimeout(fileChanges.get(changedPath));
|
||||
}
|
||||
|
||||
// Set new timeout for this file
|
||||
const timeout = setTimeout(() => {
|
||||
fileChanges.delete(changedPath);
|
||||
if (fileChanges.size === 0) {
|
||||
runTestsAfterChange();
|
||||
}
|
||||
}, debounceTime);
|
||||
|
||||
fileChanges.set(changedPath, timeout);
|
||||
};
|
||||
|
||||
// Subscribe to all relevant events
|
||||
changeObservable.subscribe(([path]) => handleFileChange(path));
|
||||
addObservable.subscribe(([path]) => handleFileChange(path));
|
||||
unlinkObservable.subscribe(([path]) => handleFileChange(path));
|
||||
|
||||
this.logger.watchModeWaiting();
|
||||
|
||||
// Handle Ctrl+C to exit gracefully
|
||||
process.on('SIGINT', async () => {
|
||||
this.logger.watchModeStop();
|
||||
await smartchokInstance.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep the process running
|
||||
await new Promise(() => {}); // This promise never resolves
|
||||
}
|
||||
|
||||
private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||
// Check if this file should be skipped based on range
|
||||
if (this.startFromFile !== null && fileIndex < this.startFromFile) {
|
||||
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `before start range (${this.startFromFile})`);
|
||||
tapCombinator.addSkippedFile(fileNameArg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopAtFile !== null && fileIndex > this.stopAtFile) {
|
||||
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `after stop range (${this.stopAtFile})`);
|
||||
tapCombinator.addSkippedFile(fileNameArg);
|
||||
return;
|
||||
}
|
||||
|
||||
// File is in range, run it
|
||||
await this.runSingleTest(fileNameArg, fileIndex, totalFiles, tapCombinator);
|
||||
}
|
||||
|
||||
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||
switch (true) {
|
||||
case process.env.CI && fileNameArg.includes('.nonci.'):
|
||||
@ -117,10 +232,81 @@ export class TsTest {
|
||||
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||
}
|
||||
|
||||
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(
|
||||
`tsrun ${fileNameArg}${tsrunOptions}`
|
||||
);
|
||||
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||
// Check for 00init.ts file in test directory
|
||||
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
|
||||
if (this.timeoutSeconds !== null) {
|
||||
const timeoutMs = this.timeoutSeconds * 1000;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(async () => {
|
||||
// Use smartshell's terminate() to kill entire process tree
|
||||
await execResultStreaming.terminate();
|
||||
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
tapParser.handleTapProcess(execResultStreaming.childProcess),
|
||||
timeoutPromise
|
||||
]);
|
||||
// Clear timeout if test completed successfully
|
||||
clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
// Handle timeout error
|
||||
tapParser.handleTimeout(this.timeoutSeconds);
|
||||
// Ensure entire process tree is killed if still running
|
||||
try {
|
||||
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
|
||||
} catch (killError) {
|
||||
// Process tree might already be dead
|
||||
}
|
||||
await tapParser.evaluateFinalResult();
|
||||
}
|
||||
} else {
|
||||
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||
}
|
||||
|
||||
return tapParser;
|
||||
}
|
||||
|
||||
@ -145,7 +331,7 @@ export class TsTest {
|
||||
});
|
||||
server.addRoute(
|
||||
'/test',
|
||||
new plugins.typedserver.servertools.Handler('GET', async (req, res) => {
|
||||
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
|
||||
res.type('.html');
|
||||
res.write(`
|
||||
<html>
|
||||
@ -178,9 +364,10 @@ export class TsTest {
|
||||
});
|
||||
});
|
||||
|
||||
// lets do the browser bit
|
||||
// lets do the browser bit with timeout handling
|
||||
await this.smartbrowserInstance.start();
|
||||
const evaluation = await this.smartbrowserInstance.evaluateOnPage(
|
||||
|
||||
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
|
||||
`http://localhost:3007/test?bundleName=${bundleFileName}`,
|
||||
async () => {
|
||||
// lets enable real time comms
|
||||
@ -193,12 +380,12 @@ export class TsTest {
|
||||
const originalError = console.error;
|
||||
|
||||
// Override console methods to capture the logs
|
||||
console.log = (...args) => {
|
||||
console.log = (...args: any[]) => {
|
||||
logStore.push(args.join(' '));
|
||||
ws.send(args.join(' '));
|
||||
originalLog(...args);
|
||||
};
|
||||
console.error = (...args) => {
|
||||
console.error = (...args: any[]) => {
|
||||
logStore.push(args.join(' '));
|
||||
ws.send(args.join(' '));
|
||||
originalError(...args);
|
||||
@ -237,16 +424,105 @@ export class TsTest {
|
||||
return logStore.join('\n');
|
||||
}
|
||||
);
|
||||
await this.smartbrowserInstance.stop();
|
||||
await server.stop();
|
||||
wss.close();
|
||||
|
||||
// Handle timeout if specified
|
||||
if (this.timeoutSeconds !== null) {
|
||||
const timeoutMs = this.timeoutSeconds * 1000;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
evaluatePromise,
|
||||
timeoutPromise
|
||||
]);
|
||||
// Clear timeout if test completed successfully
|
||||
clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
// Handle timeout error
|
||||
tapParser.handleTimeout(this.timeoutSeconds);
|
||||
}
|
||||
} else {
|
||||
await evaluatePromise;
|
||||
}
|
||||
|
||||
// Always clean up resources, even on timeout
|
||||
try {
|
||||
await this.smartbrowserInstance.stop();
|
||||
} catch (error) {
|
||||
// Browser might already be stopped
|
||||
}
|
||||
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (error) {
|
||||
// Server might already be stopped
|
||||
}
|
||||
|
||||
try {
|
||||
wss.close();
|
||||
} catch (error) {
|
||||
// WebSocket server might already be closed
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.`
|
||||
);
|
||||
// lets create the tap parser
|
||||
// Always evaluate final result (handleTimeout just sets up the test state)
|
||||
await tapParser.evaluateFinalResult();
|
||||
return tapParser;
|
||||
}
|
||||
|
||||
public async runInDeno() {}
|
||||
|
||||
private async movePreviousLogFiles() {
|
||||
const logDir = plugins.path.join('.nogit', 'testlogs');
|
||||
const previousDir = plugins.path.join('.nogit', 'testlogs', 'previous');
|
||||
const errDir = plugins.path.join('.nogit', 'testlogs', '00err');
|
||||
const diffDir = plugins.path.join('.nogit', 'testlogs', '00diff');
|
||||
|
||||
try {
|
||||
// Delete 00err and 00diff directories if they exist
|
||||
if (await plugins.smartfile.fs.isDirectory(errDir)) {
|
||||
plugins.smartfile.fs.removeSync(errDir);
|
||||
}
|
||||
if (await plugins.smartfile.fs.isDirectory(diffDir)) {
|
||||
plugins.smartfile.fs.removeSync(diffDir);
|
||||
}
|
||||
|
||||
// Get all .log files in log directory (not in subdirectories)
|
||||
const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log');
|
||||
const logFiles = files.filter((file: string) => !file.includes('/'));
|
||||
|
||||
if (logFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure previous directory exists
|
||||
await plugins.smartfile.fs.ensureDir(previousDir);
|
||||
|
||||
// Move each log file to previous directory
|
||||
for (const file of logFiles) {
|
||||
const filename = plugins.path.basename(file);
|
||||
const sourcePath = plugins.path.join(logDir, filename);
|
||||
const destPath = plugins.path.join(previousDir, filename);
|
||||
|
||||
try {
|
||||
// Copy file to new location and remove original
|
||||
await plugins.smartfile.fs.copy(sourcePath, destPath);
|
||||
await plugins.smartfile.fs.remove(sourcePath);
|
||||
} catch (error) {
|
||||
// Silently continue if a file can't be moved
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist, which is fine
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -30,12 +30,14 @@ export interface TestSummary {
|
||||
totalTests: number;
|
||||
totalPassed: number;
|
||||
totalFailed: number;
|
||||
totalSkipped: number;
|
||||
totalDuration: number;
|
||||
fileResults: TestFileResult[];
|
||||
skippedFiles: string[];
|
||||
}
|
||||
|
||||
export class TsTestLogger {
|
||||
private options: LogOptions;
|
||||
public readonly options: LogOptions;
|
||||
private startTime: number;
|
||||
private fileResults: TestFileResult[] = [];
|
||||
private currentFileResult: TestFileResult | null = null;
|
||||
@ -245,6 +247,44 @@ export class TsTestLogger {
|
||||
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
|
||||
}
|
||||
|
||||
// If using --logfile, handle error copy and diff detection
|
||||
if (this.options.logFile && this.currentTestLogFile) {
|
||||
try {
|
||||
const logContent = fs.readFileSync(this.currentTestLogFile, 'utf-8');
|
||||
const logDir = path.dirname(this.currentTestLogFile);
|
||||
const logBasename = path.basename(this.currentTestLogFile);
|
||||
|
||||
// Create error copy if there were failures
|
||||
if (failed > 0) {
|
||||
const errorDir = path.join(logDir, '00err');
|
||||
if (!fs.existsSync(errorDir)) {
|
||||
fs.mkdirSync(errorDir, { recursive: true });
|
||||
}
|
||||
const errorLogPath = path.join(errorDir, logBasename);
|
||||
fs.writeFileSync(errorLogPath, logContent);
|
||||
}
|
||||
|
||||
// Check for previous version and create diff if changed
|
||||
const previousLogPath = path.join(logDir, 'previous', logBasename);
|
||||
if (fs.existsSync(previousLogPath)) {
|
||||
const previousContent = fs.readFileSync(previousLogPath, 'utf-8');
|
||||
|
||||
// Simple check if content differs
|
||||
if (previousContent !== logContent) {
|
||||
const diffDir = path.join(logDir, '00diff');
|
||||
if (!fs.existsSync(diffDir)) {
|
||||
fs.mkdirSync(diffDir, { recursive: true });
|
||||
}
|
||||
const diffLogPath = path.join(diffDir, logBasename);
|
||||
const diffContent = this.createDiff(previousContent, logContent, logBasename);
|
||||
fs.writeFileSync(diffLogPath, diffContent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail to avoid disrupting the test run
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the current test log file reference only if using --logfile
|
||||
if (this.options.logFile) {
|
||||
this.currentTestLogFile = null;
|
||||
@ -252,7 +292,7 @@ export class TsTestLogger {
|
||||
}
|
||||
|
||||
// TAP output forwarding (for TAP protocol messages)
|
||||
tapOutput(message: string, isError: boolean = false) {
|
||||
tapOutput(message: string, _isError: boolean = false) {
|
||||
if (this.options.json) return;
|
||||
|
||||
// Never show raw TAP protocol messages in console
|
||||
@ -282,6 +322,19 @@ export class TsTestLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// Skipped test file
|
||||
testFileSkipped(filename: string, index: number, total: number, reason: string) {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'fileSkipped', filename, index, total, reason });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.quiet) return;
|
||||
|
||||
this.log(this.format(`\n⏭️ ${filename} (${index}/${total})`, 'yellow'));
|
||||
this.log(this.format(` Skipped: ${reason}`, 'dim'));
|
||||
}
|
||||
|
||||
// Browser console
|
||||
browserConsole(message: string, level: string = 'log') {
|
||||
if (this.options.json) {
|
||||
@ -317,15 +370,17 @@ export class TsTestLogger {
|
||||
}
|
||||
|
||||
// Final summary
|
||||
summary() {
|
||||
summary(skippedFiles: string[] = []) {
|
||||
const totalDuration = Date.now() - this.startTime;
|
||||
const summary: TestSummary = {
|
||||
totalFiles: this.fileResults.length,
|
||||
totalFiles: this.fileResults.length + skippedFiles.length,
|
||||
totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
|
||||
totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
|
||||
totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
|
||||
totalSkipped: skippedFiles.length,
|
||||
totalDuration,
|
||||
fileResults: this.fileResults
|
||||
fileResults: this.fileResults,
|
||||
skippedFiles
|
||||
};
|
||||
|
||||
if (this.options.json) {
|
||||
@ -346,6 +401,9 @@ export class TsTestLogger {
|
||||
this.log(this.format(`│ Total Tests: ${summary.totalTests.toString().padStart(14)} │`, 'white'));
|
||||
this.log(this.format(`│ Passed: ${summary.totalPassed.toString().padStart(14)} │`, 'green'));
|
||||
this.log(this.format(`│ Failed: ${summary.totalFailed.toString().padStart(14)} │`, summary.totalFailed > 0 ? 'red' : 'green'));
|
||||
if (summary.totalSkipped > 0) {
|
||||
this.log(this.format(`│ Skipped: ${summary.totalSkipped.toString().padStart(14)} │`, 'yellow'));
|
||||
}
|
||||
this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white'));
|
||||
this.log(this.format('└────────────────────────────────┘', 'dim'));
|
||||
|
||||
@ -385,6 +443,20 @@ export class TsTestLogger {
|
||||
this.log(this.format(`\n${status}`, statusColor));
|
||||
}
|
||||
|
||||
// Warning display
|
||||
warning(message: string) {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'warning', message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.quiet) {
|
||||
console.log(`WARNING: ${message}`);
|
||||
} else {
|
||||
this.log(this.format(` ⚠️ ${message}`, 'orange'));
|
||||
}
|
||||
}
|
||||
|
||||
// Error display
|
||||
error(message: string, file?: string, stack?: string) {
|
||||
if (this.options.json) {
|
||||
@ -404,4 +476,91 @@ export class TsTestLogger {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a diff between two log contents
|
||||
private createDiff(previousContent: string, currentContent: string, filename: string): string {
|
||||
const previousLines = previousContent.split('\n');
|
||||
const currentLines = currentContent.split('\n');
|
||||
|
||||
let diff = `DIFF REPORT: ${filename}\n`;
|
||||
diff += `Generated: ${new Date().toISOString()}\n`;
|
||||
diff += '='.repeat(80) + '\n\n';
|
||||
|
||||
// Simple line-by-line comparison
|
||||
const maxLines = Math.max(previousLines.length, currentLines.length);
|
||||
let hasChanges = false;
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const prevLine = previousLines[i] || '';
|
||||
const currLine = currentLines[i] || '';
|
||||
|
||||
if (prevLine !== currLine) {
|
||||
hasChanges = true;
|
||||
if (i < previousLines.length && i >= currentLines.length) {
|
||||
// Line was removed
|
||||
diff += `- [Line ${i + 1}] ${prevLine}\n`;
|
||||
} else if (i >= previousLines.length && i < currentLines.length) {
|
||||
// Line was added
|
||||
diff += `+ [Line ${i + 1}] ${currLine}\n`;
|
||||
} else {
|
||||
// Line was modified
|
||||
diff += `- [Line ${i + 1}] ${prevLine}\n`;
|
||||
diff += `+ [Line ${i + 1}] ${currLine}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
diff += 'No changes detected.\n';
|
||||
}
|
||||
|
||||
diff += '\n' + '='.repeat(80) + '\n';
|
||||
diff += `Previous version had ${previousLines.length} lines\n`;
|
||||
diff += `Current version has ${currentLines.length} lines\n`;
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Watch mode methods
|
||||
watchModeStart() {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'watchModeStart' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(this.format('\n👀 Watch Mode', 'cyan'));
|
||||
this.log(this.format(' Running tests in watch mode...', 'dim'));
|
||||
this.log(this.format(' Press Ctrl+C to exit\n', 'dim'));
|
||||
}
|
||||
|
||||
watchModeWaiting() {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'watchModeWaiting' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(this.format('\n Waiting for file changes...', 'dim'));
|
||||
}
|
||||
|
||||
watchModeRerun(changedFiles: string[]) {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'watchModeRerun', changedFiles });
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(this.format('\n🔄 File changes detected:', 'cyan'));
|
||||
changedFiles.forEach(file => {
|
||||
this.log(this.format(` • ${file}`, 'yellow'));
|
||||
});
|
||||
this.log(this.format('\n Re-running tests...\n', 'dim'));
|
||||
}
|
||||
|
||||
watchModeStop() {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'watchModeStop' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(this.format('\n\n👋 Stopping watch mode...', 'cyan'));
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ export {
|
||||
// @push.rocks scope
|
||||
import * as consolecolor from '@push.rocks/consolecolor';
|
||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||
import * as smartchok from '@push.rocks/smartchok';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
@ -23,6 +24,7 @@ import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
||||
export {
|
||||
consolecolor,
|
||||
smartbrowser,
|
||||
smartchok,
|
||||
smartdelay,
|
||||
smartfile,
|
||||
smartlog,
|
||||
|
@ -3,6 +3,5 @@ export { TapWrap } from './tapbundle.classes.tapwrap.js';
|
||||
export { webhelpers } from './webhelpers.js';
|
||||
export { TapTools } from './tapbundle.classes.taptools.js';
|
||||
|
||||
import { expect } from '@push.rocks/smartexpect';
|
||||
|
||||
export { expect };
|
||||
// Export enhanced expect with diff generation
|
||||
export { expect, setProtocolEmitter } from './tapbundle.expect.wrapper.js';
|
||||
|
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 { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
|
||||
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
import type { ITapSettings } from './tapbundle.interfaces.js';
|
||||
import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
|
||||
|
||||
export interface ITestSuite {
|
||||
description: string;
|
||||
@ -102,6 +105,8 @@ class TestBuilder<T> {
|
||||
}
|
||||
|
||||
export class Tap<T> {
|
||||
private protocolEmitter = new ProtocolEmitter();
|
||||
private settingsManager = new SettingsManager();
|
||||
private _skipCount = 0;
|
||||
private _filterTags: string[] = [];
|
||||
|
||||
@ -139,12 +144,27 @@ export class Tap<T> {
|
||||
*/
|
||||
public skip = {
|
||||
test: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
||||
console.log(`skipped test: ${descriptionArg}`);
|
||||
this._skipCount++;
|
||||
const skippedTest = this.test(descriptionArg, functionArg, 'skip');
|
||||
return skippedTest;
|
||||
},
|
||||
testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
||||
console.log(`skipped test: ${descriptionArg}`);
|
||||
this._skipCount++;
|
||||
const skippedTest = new TapTest<T>({
|
||||
description: descriptionArg,
|
||||
testFunction: functionArg,
|
||||
parallel: true,
|
||||
});
|
||||
|
||||
// Mark as skip mode
|
||||
skippedTest.tapTools.markAsSkipped('Marked as skip');
|
||||
|
||||
// Add to appropriate test list
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(skippedTest);
|
||||
} else {
|
||||
this._tapTests.push(skippedTest);
|
||||
}
|
||||
|
||||
return skippedTest;
|
||||
},
|
||||
};
|
||||
|
||||
@ -153,7 +173,65 @@ export class Tap<T> {
|
||||
*/
|
||||
public only = {
|
||||
test: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
|
||||
this.test(descriptionArg, testFunctionArg, 'only');
|
||||
return this.test(descriptionArg, testFunctionArg, 'only');
|
||||
},
|
||||
testParallel: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
|
||||
const onlyTest = new TapTest<T>({
|
||||
description: descriptionArg,
|
||||
testFunction: testFunctionArg,
|
||||
parallel: true,
|
||||
});
|
||||
|
||||
// Add to only tests list
|
||||
this._tapTestsOnly.push(onlyTest);
|
||||
|
||||
return onlyTest;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* mark a test as todo (not yet implemented)
|
||||
*/
|
||||
public todo = {
|
||||
test: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
|
||||
const defaultFunc = (async () => {}) as ITestFunction<T>;
|
||||
const todoTest = new TapTest<T>({
|
||||
description: descriptionArg,
|
||||
testFunction: functionArg || defaultFunc,
|
||||
parallel: false,
|
||||
});
|
||||
|
||||
// Mark as todo
|
||||
todoTest.tapTools.todo('Marked as todo');
|
||||
|
||||
// Add to appropriate test list
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(todoTest);
|
||||
} else {
|
||||
this._tapTests.push(todoTest);
|
||||
}
|
||||
|
||||
return todoTest;
|
||||
},
|
||||
testParallel: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
|
||||
const defaultFunc = (async () => {}) as ITestFunction<T>;
|
||||
const todoTest = new TapTest<T>({
|
||||
description: descriptionArg,
|
||||
testFunction: functionArg || defaultFunc,
|
||||
parallel: true,
|
||||
});
|
||||
|
||||
// Mark as todo
|
||||
todoTest.tapTools.todo('Marked as todo');
|
||||
|
||||
// Add to appropriate test list
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(todoTest);
|
||||
} else {
|
||||
this._tapTests.push(todoTest);
|
||||
}
|
||||
|
||||
return todoTest;
|
||||
},
|
||||
};
|
||||
|
||||
@ -163,6 +241,21 @@ export class Tap<T> {
|
||||
private _currentSuite: ITestSuite | null = null;
|
||||
private _rootSuites: ITestSuite[] = [];
|
||||
|
||||
/**
|
||||
* Configure global test settings
|
||||
*/
|
||||
public settings(settings: ITapSettings): this {
|
||||
this.settingsManager.setGlobalSettings(settings);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current test settings
|
||||
*/
|
||||
public getSettings(): ITapSettings {
|
||||
return this.settingsManager.getSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal test function, will run one by one
|
||||
* @param testDescription - A description of what the test does
|
||||
@ -179,14 +272,26 @@ export class Tap<T> {
|
||||
parallel: false,
|
||||
});
|
||||
|
||||
// No options applied here - use the fluent builder syntax instead
|
||||
// Apply default settings from settings manager
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.timeout !== undefined) {
|
||||
localTest.timeoutMs = settings.timeout;
|
||||
}
|
||||
if (settings.retries !== undefined) {
|
||||
localTest.tapTools.retry(settings.retries);
|
||||
}
|
||||
|
||||
// Handle skip mode
|
||||
if (modeArg === 'skip') {
|
||||
localTest.tapTools.markAsSkipped('Marked as skip');
|
||||
}
|
||||
|
||||
// If we're in a suite, add test to the suite
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(localTest);
|
||||
} else {
|
||||
// Otherwise add to global test list
|
||||
if (modeArg === 'normal') {
|
||||
if (modeArg === 'normal' || modeArg === 'skip') {
|
||||
this._tapTests.push(localTest);
|
||||
} else if (modeArg === 'only') {
|
||||
this._tapTestsOnly.push(localTest);
|
||||
@ -211,6 +316,15 @@ export class Tap<T> {
|
||||
parallel: true,
|
||||
});
|
||||
|
||||
// Apply default settings from settings manager
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.timeout !== undefined) {
|
||||
localTest.timeoutMs = settings.timeout;
|
||||
}
|
||||
if (settings.retries !== undefined) {
|
||||
localTest.tapTools.retry(settings.retries);
|
||||
}
|
||||
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(localTest);
|
||||
} else {
|
||||
@ -336,8 +450,27 @@ export class Tap<T> {
|
||||
await preTask.run();
|
||||
}
|
||||
|
||||
// Count actual tests that will be run
|
||||
console.log(`1..${concerningTests.length}`);
|
||||
// Emit protocol header and TAP version
|
||||
console.log(this.protocolEmitter.emitProtocolHeader());
|
||||
console.log(this.protocolEmitter.emitTapVersion(13));
|
||||
|
||||
// Emit test plan
|
||||
const plan = {
|
||||
start: 1,
|
||||
end: concerningTests.length
|
||||
};
|
||||
console.log(this.protocolEmitter.emitPlan(plan));
|
||||
|
||||
// Run global beforeAll hook if configured
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.beforeAll) {
|
||||
try {
|
||||
await settings.beforeAll();
|
||||
} catch (error) {
|
||||
console.error('Error in beforeAll hook:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests from suites with lifecycle hooks
|
||||
let testKey = 0;
|
||||
@ -365,6 +498,33 @@ export class Tap<T> {
|
||||
});
|
||||
|
||||
for (const currentTest of nonSuiteTests) {
|
||||
// Wrap test function with global lifecycle hooks
|
||||
const originalFunction = currentTest.testFunction;
|
||||
const testName = currentTest.description;
|
||||
currentTest.testFunction = async (tapTools) => {
|
||||
// Run global beforeEach if configured
|
||||
if (settings.beforeEach) {
|
||||
await settings.beforeEach(testName);
|
||||
}
|
||||
|
||||
// Run the actual test
|
||||
let testPassed = true;
|
||||
let result: any;
|
||||
try {
|
||||
result = await originalFunction(tapTools);
|
||||
} catch (error) {
|
||||
testPassed = false;
|
||||
throw error;
|
||||
} finally {
|
||||
// Run global afterEach if configured
|
||||
if (settings.afterEach) {
|
||||
await settings.afterEach(testName, testPassed);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const testPromise = currentTest.run(testKey++);
|
||||
if (currentTest.parallel) {
|
||||
promiseArray.push(testPromise);
|
||||
@ -394,6 +554,16 @@ export class Tap<T> {
|
||||
console.log(failReason);
|
||||
}
|
||||
|
||||
// Run global afterAll hook if configured
|
||||
if (settings.afterAll) {
|
||||
try {
|
||||
await settings.afterAll();
|
||||
} catch (error) {
|
||||
console.error('Error in afterAll hook:', error);
|
||||
// Don't throw here, we want to complete the test run
|
||||
}
|
||||
}
|
||||
|
||||
if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) {
|
||||
if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1);
|
||||
}
|
||||
@ -402,6 +572,13 @@ export class Tap<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
private emitEvent(event: ITestEvent) {
|
||||
console.log(this.protocolEmitter.emitEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tests in a suite with lifecycle hooks
|
||||
*/
|
||||
@ -412,6 +589,14 @@ export class Tap<T> {
|
||||
context: { testKey: number }
|
||||
) {
|
||||
for (const suite of suites) {
|
||||
// Emit suite:started event
|
||||
this.emitEvent({
|
||||
eventType: 'suite:started',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
suiteName: suite.description
|
||||
}
|
||||
});
|
||||
// Run beforeEach from parent suites
|
||||
const beforeEachFunctions: ITestFunction<any>[] = [];
|
||||
let currentSuite: ITestSuite | null = suite;
|
||||
@ -426,27 +611,46 @@ export class Tap<T> {
|
||||
for (const test of suite.tests) {
|
||||
// Create wrapper test function that includes lifecycle hooks
|
||||
const originalFunction = test.testFunction;
|
||||
const testName = test.description;
|
||||
test.testFunction = async (tapTools) => {
|
||||
// Run all beforeEach hooks
|
||||
// Run global beforeEach if configured
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.beforeEach) {
|
||||
await settings.beforeEach(testName);
|
||||
}
|
||||
|
||||
// Run all suite beforeEach hooks
|
||||
for (const beforeEach of beforeEachFunctions) {
|
||||
await beforeEach(tapTools);
|
||||
}
|
||||
|
||||
// Run the actual test
|
||||
const result = await originalFunction(tapTools);
|
||||
|
||||
// Run afterEach hooks in reverse order
|
||||
const afterEachFunctions: ITestFunction<any>[] = [];
|
||||
currentSuite = suite;
|
||||
while (currentSuite) {
|
||||
if (currentSuite.afterEach) {
|
||||
afterEachFunctions.push(currentSuite.afterEach);
|
||||
let testPassed = true;
|
||||
let result: any;
|
||||
try {
|
||||
result = await originalFunction(tapTools);
|
||||
} catch (error) {
|
||||
testPassed = false;
|
||||
throw error;
|
||||
} finally {
|
||||
// Run afterEach hooks in reverse order
|
||||
const afterEachFunctions: ITestFunction<any>[] = [];
|
||||
currentSuite = suite;
|
||||
while (currentSuite) {
|
||||
if (currentSuite.afterEach) {
|
||||
afterEachFunctions.push(currentSuite.afterEach);
|
||||
}
|
||||
currentSuite = currentSuite.parent || null;
|
||||
}
|
||||
|
||||
for (const afterEach of afterEachFunctions) {
|
||||
await afterEach(tapTools);
|
||||
}
|
||||
|
||||
// Run global afterEach if configured
|
||||
if (settings.afterEach) {
|
||||
await settings.afterEach(testName, testPassed);
|
||||
}
|
||||
currentSuite = currentSuite.parent || null;
|
||||
}
|
||||
|
||||
for (const afterEach of afterEachFunctions) {
|
||||
await afterEach(tapTools);
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -462,6 +666,15 @@ export class Tap<T> {
|
||||
|
||||
// Recursively run child suites
|
||||
await this._runSuite(suite, suite.children, promiseArray, context);
|
||||
|
||||
// Emit suite:completed event
|
||||
this.emitEvent({
|
||||
eventType: 'suite:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
suiteName: suite.description
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import * as plugins from './tapbundle.plugins.js';
|
||||
import { tapCreator } from './tapbundle.tapcreator.js';
|
||||
import { TapTools, SkipError } from './tapbundle.classes.taptools.js';
|
||||
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
import { setProtocolEmitter } from './tapbundle.expect.wrapper.js';
|
||||
|
||||
// imported interfaces
|
||||
import { Deferred } from '@push.rocks/smartpromise';
|
||||
@ -32,6 +34,7 @@ export class TapTest<T = unknown> {
|
||||
public testPromise: Promise<TapTest<T>> = this.testDeferred.promise;
|
||||
private testResultDeferred: Deferred<T> = plugins.smartpromise.defer();
|
||||
public testResultPromise: Promise<T> = this.testResultDeferred.promise;
|
||||
private protocolEmitter = new ProtocolEmitter();
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
@ -48,6 +51,13 @@ export class TapTest<T = unknown> {
|
||||
this.testFunction = optionsArg.testFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
private emitEvent(event: ITestEvent) {
|
||||
console.log(this.protocolEmitter.emitEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* run the test
|
||||
*/
|
||||
@ -55,11 +65,74 @@ export class TapTest<T = unknown> {
|
||||
this.testKey = testKeyArg;
|
||||
const testNumber = testKeyArg + 1;
|
||||
|
||||
// Emit test:queued event
|
||||
this.emitEvent({
|
||||
eventType: 'test:queued',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description
|
||||
}
|
||||
});
|
||||
|
||||
// Handle todo tests
|
||||
if (this.isTodo) {
|
||||
const todoText = this.todoReason ? `# TODO ${this.todoReason}` : '# TODO';
|
||||
console.log(`ok ${testNumber} - ${this.description} ${todoText}`);
|
||||
const testResult = {
|
||||
ok: true,
|
||||
testNumber,
|
||||
description: this.description,
|
||||
directive: {
|
||||
type: 'todo' as const,
|
||||
reason: this.todoReason
|
||||
}
|
||||
};
|
||||
const lines = this.protocolEmitter.emitTest(testResult);
|
||||
lines.forEach((line: string) => console.log(line));
|
||||
this.status = 'success';
|
||||
|
||||
// Emit test:completed event for todo test
|
||||
this.emitEvent({
|
||||
eventType: 'test:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
duration: 0,
|
||||
error: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.testDeferred.resolve(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pre-marked skip tests
|
||||
if (this.tapTools.isSkipped) {
|
||||
const testResult = {
|
||||
ok: true,
|
||||
testNumber,
|
||||
description: this.description,
|
||||
directive: {
|
||||
type: 'skip' as const,
|
||||
reason: this.tapTools.skipReason || 'Marked as skip'
|
||||
}
|
||||
};
|
||||
const lines = this.protocolEmitter.emitTest(testResult);
|
||||
lines.forEach((line: string) => console.log(line));
|
||||
this.status = 'skipped';
|
||||
|
||||
// Emit test:completed event for skipped test
|
||||
this.emitEvent({
|
||||
eventType: 'test:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
duration: 0,
|
||||
error: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.testDeferred.resolve(this);
|
||||
return;
|
||||
}
|
||||
@ -71,6 +144,20 @@ export class TapTest<T = unknown> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
this.hrtMeasurement.start();
|
||||
|
||||
// Emit test:started event
|
||||
this.emitEvent({
|
||||
eventType: 'test:started',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
retry: attempt > 0 ? attempt : undefined
|
||||
}
|
||||
});
|
||||
|
||||
// Set protocol emitter for enhanced expect
|
||||
setProtocolEmitter(this.protocolEmitter);
|
||||
|
||||
try {
|
||||
// Set up timeout if specified
|
||||
let timeoutHandle: any;
|
||||
@ -97,10 +184,32 @@ export class TapTest<T = unknown> {
|
||||
}
|
||||
|
||||
this.hrtMeasurement.stop();
|
||||
console.log(
|
||||
`ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
|
||||
);
|
||||
const testResult = {
|
||||
ok: true,
|
||||
testNumber,
|
||||
description: this.description,
|
||||
metadata: {
|
||||
time: this.hrtMeasurement.milliSeconds,
|
||||
tags: this.tags.length > 0 ? this.tags : undefined,
|
||||
file: this.fileName
|
||||
}
|
||||
};
|
||||
const lines = this.protocolEmitter.emitTest(testResult);
|
||||
lines.forEach((line: string) => console.log(line));
|
||||
this.status = 'success';
|
||||
|
||||
// Emit test:completed event
|
||||
this.emitEvent({
|
||||
eventType: 'test:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
duration: this.hrtMeasurement.milliSeconds,
|
||||
error: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.testDeferred.resolve(this);
|
||||
this.testResultDeferred.resolve(testReturnValue);
|
||||
return; // Success, exit retry loop
|
||||
@ -110,8 +219,31 @@ export class TapTest<T = unknown> {
|
||||
|
||||
// Handle skip
|
||||
if (err instanceof SkipError || err.name === 'SkipError') {
|
||||
console.log(`ok ${testNumber} - ${this.description} # SKIP ${err.message.replace('Skipped: ', '')}`);
|
||||
const testResult = {
|
||||
ok: true,
|
||||
testNumber,
|
||||
description: this.description,
|
||||
directive: {
|
||||
type: 'skip' as const,
|
||||
reason: err.message.replace('Skipped: ', '')
|
||||
}
|
||||
};
|
||||
const lines = this.protocolEmitter.emitTest(testResult);
|
||||
lines.forEach((line: string) => console.log(line));
|
||||
this.status = 'skipped';
|
||||
|
||||
// Emit test:completed event for skipped test
|
||||
this.emitEvent({
|
||||
eventType: 'test:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
duration: this.hrtMeasurement.milliSeconds,
|
||||
error: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.testDeferred.resolve(this);
|
||||
return;
|
||||
}
|
||||
@ -120,17 +252,48 @@ export class TapTest<T = unknown> {
|
||||
|
||||
// If we have retries left, try again
|
||||
if (attempt < maxRetries) {
|
||||
console.log(
|
||||
`# Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`,
|
||||
);
|
||||
console.log(this.protocolEmitter.emitComment(`Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`));
|
||||
this.tapTools._incrementRetryCount();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Final failure
|
||||
console.log(
|
||||
`not ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
|
||||
);
|
||||
const testResult = {
|
||||
ok: false,
|
||||
testNumber,
|
||||
description: this.description,
|
||||
metadata: {
|
||||
time: this.hrtMeasurement.milliSeconds,
|
||||
retry: this.tapTools.retryCount,
|
||||
maxRetries: maxRetries > 0 ? maxRetries : undefined,
|
||||
error: {
|
||||
message: lastError.message || String(lastError),
|
||||
stack: lastError.stack,
|
||||
code: lastError.code
|
||||
},
|
||||
tags: this.tags.length > 0 ? this.tags : undefined,
|
||||
file: this.fileName
|
||||
}
|
||||
};
|
||||
const lines = this.protocolEmitter.emitTest(testResult);
|
||||
lines.forEach((line: string) => console.log(line));
|
||||
|
||||
// Emit test:completed event for failed test
|
||||
this.emitEvent({
|
||||
eventType: 'test:completed',
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
testNumber,
|
||||
description: this.description,
|
||||
duration: this.hrtMeasurement.milliSeconds,
|
||||
error: {
|
||||
message: lastError.message || String(lastError),
|
||||
stack: lastError.stack,
|
||||
type: 'runtime' as const
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.testDeferred.resolve(this);
|
||||
this.testResultDeferred.resolve(err);
|
||||
|
||||
|
@ -22,6 +22,10 @@ export class TapTools {
|
||||
public testData: any = {};
|
||||
private static _sharedContext = new Map<string, any>();
|
||||
private _snapshotPath: string = '';
|
||||
|
||||
// Flags for skip/todo
|
||||
private _isSkipped = false;
|
||||
private _skipReason?: string;
|
||||
|
||||
constructor(TapTestArg: TapTest<any>) {
|
||||
this._tapTest = TapTestArg;
|
||||
@ -45,9 +49,33 @@ export class TapTools {
|
||||
* skip the rest of the test
|
||||
*/
|
||||
public skip(reason?: string): never {
|
||||
this._isSkipped = true;
|
||||
this._skipReason = reason;
|
||||
const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped';
|
||||
throw new SkipError(skipMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark test as skipped without throwing (for pre-marking)
|
||||
*/
|
||||
public markAsSkipped(reason?: string): void {
|
||||
this._isSkipped = true;
|
||||
this._skipReason = reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if test is marked as skipped
|
||||
*/
|
||||
public get isSkipped(): boolean {
|
||||
return this._isSkipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skip reason
|
||||
*/
|
||||
public get skipReason(): string | undefined {
|
||||
return this._skipReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* conditionally skip the rest of the test
|
||||
|
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;
|
||||
}
|
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;
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
{
|
||||
"order": 1
|
||||
"order": 2
|
||||
}
|
3
ts_tapbundle_node/tspublish.json
Normal file
3
ts_tapbundle_node/tspublish.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 3
|
||||
}
|
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';
|
3
ts_tapbundle_protocol/tspublish.json
Normal file
3
ts_tapbundle_protocol/tspublish.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 1
|
||||
}
|
Reference in New Issue
Block a user