Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
c26145205f | |||
82fc22653b | |||
3d85f54be0 | |||
9464c17c15 | |||
91b99ce304 | |||
899045e6aa | |||
845f146e91 | |||
d1f8652fc7 | |||
f717078558 | |||
d2c0e533b5 | |||
d3c7fce595 | |||
570e2d6b3b | |||
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 |
148
changelog.md
148
changelog.md
@ -1,5 +1,153 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.3.0 - feat(cli)
|
||||||
|
Add '--version' option and warn against global tstest usage in the tstest project
|
||||||
|
|
||||||
|
- Introduced a new '--version' CLI flag that prints the version from package.json
|
||||||
|
- Added logic in ts/index.ts to detect if tstest is run globally within its own project and issue a warning
|
||||||
|
- Added .claude/settings.local.json to configure allowed permissions for various commands
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.2.6 - fix(tstest)
|
||||||
|
Improve timeout warning timer management and summary output formatting in the test runner.
|
||||||
|
|
||||||
|
- Removed the global timeoutWarningTimer and replaced it with local warning timers in runInNode and runInChrome methods.
|
||||||
|
- Added warnings when test files run for over one minute if no timeout is specified.
|
||||||
|
- Ensured proper clearing of warning timers on successful completion or timeout.
|
||||||
|
- Enhanced quiet mode summary output to clearly display passed and failed test counts.
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.2.5 - fix(protocol)
|
||||||
|
Fix inline timing metadata parsing and enhance test coverage for performance metrics and timing edge cases
|
||||||
|
|
||||||
|
- Updated the protocol parser to correctly parse inline key:value pairs while excluding prefixed formats (META:, SKIP:, TODO:, EVENT:)
|
||||||
|
- Added new tests for performance metrics, timing edge cases, and protocol timing to verify accurate timing capture and retry handling
|
||||||
|
- Expanded documentation in readme.hints.md to detail the updated timing implementation and parser fixes
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.2.4 - fix(logging)
|
||||||
|
Improve performance metrics reporting and add local permissions configuration
|
||||||
|
|
||||||
|
- Add .claude/settings.local.json to configure allowed permissions for various commands
|
||||||
|
- Update tstest logging: compute average test duration from actual durations and adjust slowest test display formatting
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.2.3 - fix(readme/ts/tstest.plugins)
|
||||||
|
Update npm package scope and documentation to use '@git.zone' instead of '@gitzone', and add local settings configuration.
|
||||||
|
|
||||||
|
- Changed npm package links and source repository URLs in readme from '@gitzone/tstest' to '@git.zone/tstest'.
|
||||||
|
- Updated comments in ts/tstest.plugins.ts to reflect the correct '@git.zone' scope.
|
||||||
|
- Added .claude/settings.local.json file with local permission settings.
|
||||||
|
|
||||||
|
## 2025-05-26 - 2.2.2 - fix(config)
|
||||||
|
Cleanup project configuration by adding local CLAUDE settings and removing redundant license files
|
||||||
|
|
||||||
|
- Added .claude/settings.local.json with updated permissions for CLI and build tasks
|
||||||
|
- Removed license and license.md files to streamline repository content
|
||||||
|
|
||||||
|
## 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)
|
## 2025-05-23 - 1.9.3 - fix(tstest)
|
||||||
Fix test timing display issue and update TAP protocol documentation
|
Fix test timing display issue and update TAP protocol documentation
|
||||||
|
|
||||||
|
19
license.md
Normal file
19
license.md
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": {
|
"gitzone": {
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "gitlab.com",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "gitzone",
|
"gitscope": "git.zone",
|
||||||
"gitrepo": "tstest",
|
"gitrepo": "tstest",
|
||||||
"description": "a test utility to run tests that match test/**/*.ts",
|
"description": "a test utility to run tests that match test/**/*.ts",
|
||||||
"npmPackagename": "@gitzone/tstest",
|
"npmPackagename": "@git.zone/tstest",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tstest",
|
"name": "@git.zone/tstest",
|
||||||
"version": "1.9.3",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a test utility to run tests that match test/**/*.ts",
|
"description": "a test utility to run tests that match test/**/*.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@ -34,6 +34,7 @@
|
|||||||
"@push.rocks/consolecolor": "^2.0.2",
|
"@push.rocks/consolecolor": "^2.0.2",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
"@push.rocks/smartbrowser": "^2.0.8",
|
"@push.rocks/smartbrowser": "^2.0.8",
|
||||||
|
"@push.rocks/smartchok": "^1.0.34",
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartenv": "^5.0.12",
|
"@push.rocks/smartenv": "^5.0.12",
|
||||||
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -26,6 +26,9 @@ importers:
|
|||||||
'@push.rocks/smartbrowser':
|
'@push.rocks/smartbrowser':
|
||||||
specifier: ^2.0.8
|
specifier: ^2.0.8
|
||||||
version: 2.0.8
|
version: 2.0.8
|
||||||
|
'@push.rocks/smartchok':
|
||||||
|
specifier: ^1.0.34
|
||||||
|
version: 1.0.34
|
||||||
'@push.rocks/smartcrypto':
|
'@push.rocks/smartcrypto':
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
version: 2.0.4
|
version: 2.0.4
|
||||||
|
226
readme.hints.md
226
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
|
- Automatically detects browser environment and only enables in browser context
|
||||||
|
|
||||||
3. **Build System**
|
3. **Build System**
|
||||||
- Uses `tsbuild tsfolders` to compile TypeScript
|
- Uses `tsbuild tsfolders` to compile TypeScript (invoked by `pnpm build`)
|
||||||
- Maintains separate output directories: `/dist_ts/`, `/dist_ts_tapbundle/`, `/dist_ts_tapbundle_node/`
|
- 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
|
- 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
|
### Test Scripts
|
||||||
|
|
||||||
@ -102,6 +110,214 @@ A new internal protocol is being designed that will:
|
|||||||
- Use Unicode delimiters `⟦TSTEST:⟧` that won't conflict with test content
|
- Use Unicode delimiters `⟦TSTEST:⟧` that won't conflict with test content
|
||||||
- Support structured JSON metadata
|
- Support structured JSON metadata
|
||||||
- Allow rich error reporting with stack traces and diffs
|
- Allow rich error reporting with stack traces and diffs
|
||||||
- Maintain backwards compatibility during migration
|
- Completely replace v1 protocol (no backwards compatibility)
|
||||||
|
|
||||||
See `readme.protocol.md` for the full specification and `tapbundle.protocols.ts` for the implementation utilities.
|
### 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.
|
||||||
|
|
||||||
|
## Test Timing Implementation
|
||||||
|
|
||||||
|
### Timing Architecture
|
||||||
|
|
||||||
|
Test timing is captured using `@push.rocks/smarttime`'s `HrtMeasurement` class, which provides high-resolution timing:
|
||||||
|
|
||||||
|
1. **Timing Capture**:
|
||||||
|
- Each `TapTest` instance has its own `HrtMeasurement`
|
||||||
|
- Timer starts immediately before test function execution
|
||||||
|
- Timer stops after test completes (or fails/times out)
|
||||||
|
- Millisecond precision is used for reporting
|
||||||
|
|
||||||
|
2. **Protocol Integration**:
|
||||||
|
- Timing is embedded in TAP output using Protocol V2 markers
|
||||||
|
- Inline format for simple timing: `ok 1 - test name ⟦TSTEST:time:123⟧`
|
||||||
|
- Block format for complex metadata: `⟦TSTEST:META:{"time":456,"file":"test.ts"}⟧`
|
||||||
|
|
||||||
|
3. **Performance Metrics Calculation**:
|
||||||
|
- Average is calculated from sum of individual test times, not total runtime
|
||||||
|
- Slowest test detection prefers tests with >0ms duration
|
||||||
|
- Failed tests still contribute their execution time to metrics
|
||||||
|
|
||||||
|
### Edge Cases and Considerations
|
||||||
|
|
||||||
|
1. **Sub-millisecond Tests**:
|
||||||
|
- Very fast tests may report 0ms due to millisecond rounding
|
||||||
|
- Performance metrics handle this by showing "All tests completed in <1ms" when appropriate
|
||||||
|
|
||||||
|
2. **Special Test States**:
|
||||||
|
- **Skipped tests**: Report 0ms (not executed)
|
||||||
|
- **Todo tests**: Report 0ms (not executed)
|
||||||
|
- **Failed tests**: Report actual execution time before failure
|
||||||
|
- **Timeout tests**: Report time until timeout occurred
|
||||||
|
|
||||||
|
3. **Parallel Test Timing**:
|
||||||
|
- Each parallel test tracks its own execution time independently
|
||||||
|
- Parallel tests may have overlapping execution periods
|
||||||
|
- Total suite time reflects wall-clock time, not sum of test times
|
||||||
|
|
||||||
|
4. **Hook Timing**:
|
||||||
|
- `beforeEach`/`afterEach` hooks are not included in individual test times
|
||||||
|
- Only the actual test function execution is measured
|
||||||
|
|
||||||
|
5. **Retry Timing**:
|
||||||
|
- When tests retry, only the final attempt's duration is reported
|
||||||
|
- Each retry attempt emits separate `test:started` events
|
||||||
|
|
||||||
|
### Parser Fix for Timing Metadata
|
||||||
|
|
||||||
|
The protocol parser was fixed to correctly handle inline timing metadata:
|
||||||
|
- Changed condition from `!simpleMatch[1].includes(':')` to check for simple key:value pairs
|
||||||
|
- Excludes prefixed formats (META:, SKIP:, TODO:, EVENT:) while parsing simple formats like `time:250`
|
||||||
|
|
||||||
|
This ensures timing metadata is correctly extracted and displayed in test results.
|
295
readme.md
295
readme.md
@ -1,9 +1,9 @@
|
|||||||
# @gitzone/tstest
|
# @git.zone/tstest
|
||||||
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative!
|
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative!
|
||||||
|
|
||||||
## Availabililty and Links
|
## Availabililty and Links
|
||||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@gitzone/tstest)
|
* [npmjs.org (npm package)](https://www.npmjs.com/package/@git.zone/tstest)
|
||||||
* [code.foss.global (source)](https://code.foss.global/gitzone/tstest)
|
* [code.foss.global (source)](https://code.foss.global/git.zone/tstest)
|
||||||
|
|
||||||
## Why tstest?
|
## Why tstest?
|
||||||
|
|
||||||
@ -27,13 +27,19 @@
|
|||||||
- 🔁 **Retry Logic** - Automatically retry failing tests
|
- 🔁 **Retry Logic** - Automatically retry failing tests
|
||||||
- 🛠️ **Test Fixtures** - Create reusable test data
|
- 🛠️ **Test Fixtures** - Create reusable test data
|
||||||
- 📦 **Browser-Compatible** - Full browser support with embedded tapbundle
|
- 📦 **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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --save-dev @gitzone/tstest
|
npm install --save-dev @git.zone/tstest
|
||||||
# or with pnpm
|
# or with pnpm
|
||||||
pnpm add -D @gitzone/tstest
|
pnpm add -D @git.zone/tstest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@ -68,8 +74,14 @@ tstest "test/unit/*.ts"
|
|||||||
| `--verbose`, `-v` | Show all console output from tests |
|
| `--verbose`, `-v` | Show all console output from tests |
|
||||||
| `--no-color` | Disable colored output |
|
| `--no-color` | Disable colored output |
|
||||||
| `--json` | Output results as JSON |
|
| `--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) |
|
| `--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
|
### Example Outputs
|
||||||
|
|
||||||
@ -200,9 +212,9 @@ tap.only.test('focus on this', async () => {
|
|||||||
// Only this test will run
|
// Only this test will run
|
||||||
});
|
});
|
||||||
|
|
||||||
// Todo test
|
// Todo test - creates actual test object marked as todo
|
||||||
tap.todo('implement later', async () => {
|
tap.todo.test('implement later', async () => {
|
||||||
// Marked as todo
|
// This test will be counted but marked as todo
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chaining modifiers
|
// Chaining modifiers
|
||||||
@ -555,6 +567,115 @@ tapWrap.tap.test('wrapped test', async () => {
|
|||||||
|
|
||||||
## Advanced Features
|
## 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
|
### Glob Pattern Support
|
||||||
|
|
||||||
Run specific test patterns:
|
Run specific test patterns:
|
||||||
@ -571,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.
|
**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
|
```bash
|
||||||
tstest test/ --logfile
|
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
|
### Performance Analysis
|
||||||
|
|
||||||
@ -620,8 +815,64 @@ tstest test/ --json > test-results.json
|
|||||||
tstest test/ --quiet
|
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
|
## 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
|
### Version 1.9.2
|
||||||
- 🐛 Fixed test timing display issue (removed duplicate timing in output)
|
- 🐛 Fixed test timing display issue (removed duplicate timing in output)
|
||||||
- 📝 Improved internal protocol design documentation
|
- 📝 Improved internal protocol design documentation
|
||||||
@ -648,13 +899,21 @@ tstest test/ --quiet
|
|||||||
- 📊 Enhanced TAP parser for better test reporting
|
- 📊 Enhanced TAP parser for better test reporting
|
||||||
- 🐛 Fixed glob pattern handling in shell scripts
|
- 🐛 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)
|
### Trademarks
|
||||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
|
||||||
|
|
||||||
[](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.
|
189
readme.plan.md
189
readme.plan.md
@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
!! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !!
|
!! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !!
|
||||||
|
|
||||||
## Improved Internal Protocol (NEW - Critical)
|
## Improved Internal Protocol (NEW - Critical) ✅ COMPLETED
|
||||||
|
|
||||||
### Current Issues
|
### Current Issues ✅ RESOLVED
|
||||||
- TAP protocol uses `#` for metadata which conflicts with test descriptions containing `#`
|
- ✅ TAP protocol uses `#` for metadata which conflicts with test descriptions containing `#`
|
||||||
- Fragile regex parsing that breaks with special characters
|
- ✅ Fragile regex parsing that breaks with special characters
|
||||||
- Limited extensibility for new metadata types
|
- ✅ Limited extensibility for new metadata types
|
||||||
|
|
||||||
### Proposed Solution: Protocol V2
|
### Proposed Solution: Protocol V2 ✅ IMPLEMENTED
|
||||||
- Use Unicode delimiters `⟦TSTEST:META:{}⟧` that won't appear in test names
|
- ✅ Use Unicode delimiters `⟦TSTEST:META:{}⟧` that won't appear in test names
|
||||||
- Structured JSON metadata format
|
- ✅ Structured JSON metadata format
|
||||||
- Separate protocol blocks for complex data (errors, snapshots)
|
- ✅ Separate protocol blocks for complex data (errors, snapshots)
|
||||||
- Backwards compatible with gradual migration
|
- ✅ Complete replacement of v1 (no backwards compatibility needed)
|
||||||
|
|
||||||
### Implementation
|
### Implementation ✅ COMPLETED
|
||||||
- Phase 1: Add protocol v2 parser alongside v1
|
- ✅ Phase 1: Create protocol v2 implementation in ts_tapbundle_protocol
|
||||||
- Phase 2: Generate v2 by default with --legacy flag for v1
|
- ✅ Phase 2: Replace all v1 code in both tstest and tapbundle with v2
|
||||||
- Phase 3: Full migration to v2 in next major version
|
- ✅ 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.
|
See `readme.protocol.md` for detailed specification.
|
||||||
|
|
||||||
@ -77,19 +92,19 @@ interface TapSettings {
|
|||||||
3. **Application**: Apply settings to test execution
|
3. **Application**: Apply settings to test execution
|
||||||
4. **Advanced**: Parallel execution and snapshot configuration
|
4. **Advanced**: Parallel execution and snapshot configuration
|
||||||
|
|
||||||
## 1. Enhanced Communication Between tapbundle and tstest
|
## 1. Enhanced Communication Between tapbundle and tstest ✅ COMPLETED
|
||||||
|
|
||||||
### 1.1 Real-time Test Progress API
|
### 1.1 Real-time Test Progress API ✅ COMPLETED
|
||||||
- Create a bidirectional communication channel between tapbundle and tstest
|
- ✅ Create a bidirectional communication channel between tapbundle and tstest
|
||||||
- Emit events for test lifecycle stages (start, progress, completion)
|
- ✅ Emit events for test lifecycle stages (start, progress, completion)
|
||||||
- Allow tstest to subscribe to tapbundle events for better progress reporting
|
- ✅ Allow tstest to subscribe to tapbundle events for better progress reporting
|
||||||
- Implement a standardized message format for test metadata
|
- ✅ Implement a standardized message format for test metadata
|
||||||
|
|
||||||
### 1.2 Rich Error Reporting
|
### 1.2 Rich Error Reporting ✅ COMPLETED
|
||||||
- Pass structured error objects from tapbundle to tstest
|
- ✅ Pass structured error objects from tapbundle to tstest
|
||||||
- Include stack traces, code snippets, and contextual information
|
- ✅ Include stack traces, code snippets, and contextual information
|
||||||
- Support for error categorization (assertion failures, timeouts, uncaught exceptions)
|
- ✅ Support for error categorization (assertion failures, timeouts, uncaught exceptions)
|
||||||
- Visual diff output for failed assertions
|
- ✅ Visual diff output for failed assertions
|
||||||
|
|
||||||
## 2. Enhanced toolsArg Functionality
|
## 2. Enhanced toolsArg Functionality
|
||||||
|
|
||||||
@ -134,13 +149,15 @@ tap.test('performance test', async (toolsArg) => {
|
|||||||
## 5. Test Execution Improvements
|
## 5. Test Execution Improvements
|
||||||
|
|
||||||
|
|
||||||
### 5.2 Watch Mode
|
### 5.2 Watch Mode ✅ COMPLETED
|
||||||
- Automatically re-run tests on file changes
|
- Automatically re-run tests on file changes
|
||||||
- Intelligent test selection based on changed files
|
- Debounced file change detection (300ms)
|
||||||
- Fast feedback loop for development
|
- Clear console output between runs
|
||||||
- Integration with IDE/editor plugins
|
- Shows which files triggered re-runs
|
||||||
|
- Graceful exit with Ctrl+C
|
||||||
|
- `--watch-ignore` option for excluding patterns
|
||||||
|
|
||||||
### 5.3 Advanced Test Filtering (Partial)
|
### 5.3 Advanced Test Filtering (Partial) ⚠️
|
||||||
```typescript
|
```typescript
|
||||||
// Exclude tests by pattern (not yet implemented)
|
// Exclude tests by pattern (not yet implemented)
|
||||||
tstest --exclude "**/slow/**"
|
tstest --exclude "**/slow/**"
|
||||||
@ -182,30 +199,38 @@ tstest --changed
|
|||||||
|
|
||||||
## Implementation Phases
|
## Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Improved Internal Protocol (Priority: Critical) (NEW)
|
### Phase 1: Improved Internal Protocol (Priority: Critical) ✅ COMPLETED
|
||||||
1. Implement Protocol V2 parser in tstest
|
1. ✅ Create ts_tapbundle_protocol directory with isomorphic protocol v2 implementation
|
||||||
2. Add protocol version negotiation
|
- ✅ Implement ProtocolEmitter class for message generation
|
||||||
3. Update tapbundle to generate V2 format with feature flag
|
- ✅ Implement ProtocolParser class for message parsing
|
||||||
4. Test with real-world test suites containing special characters
|
- ✅ 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: Test Configuration System (Priority: High)
|
### Phase 2: Test Configuration System (Priority: High) ✅ COMPLETED
|
||||||
1. Implement tap.settings() API with TypeScript interfaces
|
1. ✅ Implement tap.settings() API with TypeScript interfaces
|
||||||
2. Add 00init.ts discovery and loading mechanism
|
2. ✅ Add 00init.ts discovery and loading mechanism
|
||||||
3. Implement settings inheritance and merge logic
|
3. ✅ Implement settings inheritance and merge logic
|
||||||
4. Apply settings to test execution (timeouts, retries, etc.)
|
4. ✅ Apply settings to test execution (timeouts, retries, etc.)
|
||||||
|
|
||||||
### Phase 3: Enhanced Communication (Priority: High)
|
### Phase 3: Enhanced Communication (Priority: High) ✅ COMPLETED
|
||||||
1. Build on Protocol V2 for richer communication
|
1. ✅ Build on Protocol V2 for richer communication
|
||||||
2. Implement real-time test progress API
|
2. ✅ Implement real-time test progress API
|
||||||
3. Add structured error reporting with diffs and traces
|
3. ✅ Add structured error reporting with diffs and traces
|
||||||
|
|
||||||
### Phase 4: Developer Experience (Priority: Medium)
|
### Phase 4: Developer Experience (Priority: Medium) ❌ NOT STARTED
|
||||||
1. Add watch mode
|
1. Add watch mode
|
||||||
2. Implement custom reporters
|
2. Implement custom reporters
|
||||||
3. Complete advanced test filtering options
|
3. Complete advanced test filtering options
|
||||||
4. Add performance benchmarking API
|
4. Add performance benchmarking API
|
||||||
|
|
||||||
### Phase 5: Analytics and Performance (Priority: Low)
|
### Phase 5: Analytics and Performance (Priority: Low) ❌ NOT STARTED
|
||||||
1. Build test analytics dashboard
|
1. Build test analytics dashboard
|
||||||
2. Implement coverage integration
|
2. Implement coverage integration
|
||||||
3. Create trend analysis tools
|
3. Create trend analysis tools
|
||||||
@ -214,10 +239,10 @@ tstest --changed
|
|||||||
## Technical Considerations
|
## Technical Considerations
|
||||||
|
|
||||||
### API Design Principles
|
### API Design Principles
|
||||||
- Maintain backward compatibility
|
- Clean, modern API design without legacy constraints
|
||||||
- Progressive enhancement approach
|
- Progressive enhancement approach
|
||||||
- Opt-in features to avoid breaking changes
|
- Well-documented features and APIs
|
||||||
- Clear migration paths for new features
|
- Clear, simple interfaces
|
||||||
|
|
||||||
### Performance Goals
|
### Performance Goals
|
||||||
- Minimal overhead for test execution
|
- Minimal overhead for test execution
|
||||||
@ -229,4 +254,68 @@ tstest --changed
|
|||||||
- Clean interfaces between tstest and tapbundle
|
- Clean interfaces between tstest and tapbundle
|
||||||
- Extensible plugin architecture
|
- Extensible plugin architecture
|
||||||
- Standard test result format
|
- Standard test result format
|
||||||
- Compatible with existing CI/CD tools
|
- Compatible with existing CI/CD tools
|
||||||
|
|
||||||
|
## Summary of Remaining Work
|
||||||
|
|
||||||
|
### ✅ Completed
|
||||||
|
- **Protocol V2**: Full implementation with Unicode delimiters, structured metadata, and special character handling
|
||||||
|
- **Test Configuration System**: tap.settings() API, 00init.ts discovery, settings inheritance, lifecycle hooks
|
||||||
|
- **Enhanced Communication**: Event-based test lifecycle reporting, visual diff output for assertion failures, real-time test progress API
|
||||||
|
- **Rich Error Reporting**: Stack traces, error metadata, and visual diffs through protocol
|
||||||
|
- **Tags Filtering**: `--tags` option for running specific tagged tests
|
||||||
|
|
||||||
|
### ✅ Existing Features (Not in Plan)
|
||||||
|
- **Timeout Support**: `--timeout` option and per-test timeouts
|
||||||
|
- **Test Retries**: `tap.retry()` for flaky test handling
|
||||||
|
- **Parallel Tests**: `.testParallel()` for concurrent execution
|
||||||
|
- **Snapshot Testing**: Basic implementation with `toMatchSnapshot()`
|
||||||
|
- **Test Lifecycle**: `describe()` blocks with `beforeEach`/`afterEach`
|
||||||
|
- **Skip Tests**: `tap.skip.test()` (though it doesn't create test objects)
|
||||||
|
- **Log Files**: `--logfile` option saves output to `.nogit/testlogs/`
|
||||||
|
- **Test Range**: `--startFrom` and `--stopAt` for partial runs
|
||||||
|
|
||||||
|
### ⚠️ Partially Completed
|
||||||
|
- **Advanced Test Filtering**: Have `--tags` but missing `--exclude`, `--failed`, `--changed`
|
||||||
|
|
||||||
|
### ❌ Not Started
|
||||||
|
|
||||||
|
#### High Priority
|
||||||
|
|
||||||
|
#### Medium Priority
|
||||||
|
2. **Developer Experience**
|
||||||
|
- Watch mode for file changes
|
||||||
|
- Custom reporters (JSON, JUnit, HTML, Markdown)
|
||||||
|
- Performance benchmarking API
|
||||||
|
- Better error messages with suggestions
|
||||||
|
|
||||||
|
3. **Enhanced toolsArg**
|
||||||
|
- Test data injection
|
||||||
|
- Context sharing between tests
|
||||||
|
- Parameterized tests
|
||||||
|
|
||||||
|
4. **Test Organization**
|
||||||
|
- Hierarchical test suites
|
||||||
|
- Nested describe blocks
|
||||||
|
- Suite-level lifecycle hooks
|
||||||
|
|
||||||
|
#### Low Priority
|
||||||
|
5. **Analytics and Performance**
|
||||||
|
- Test analytics dashboard
|
||||||
|
- Code coverage integration
|
||||||
|
- Trend analysis
|
||||||
|
- Flaky test detection
|
||||||
|
|
||||||
|
### 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
|
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();
|
167
test/tapbundle/test.performance-metrics.ts
Normal file
167
test/tapbundle/test.performance-metrics.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
|
||||||
|
// Create tests with known, distinct timing patterns to verify metrics calculation
|
||||||
|
tap.test('metric test 1 - 10ms baseline', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 2 - 20ms double baseline', async (tools) => {
|
||||||
|
await tools.delayFor(20);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 3 - 30ms triple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(30);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 4 - 40ms quadruple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(40);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 5 - 50ms quintuple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(50);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that should be the slowest
|
||||||
|
tap.test('metric test slowest - 200ms intentionally slow', async (tools) => {
|
||||||
|
await tools.delayFor(200);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests to verify edge cases in average calculation
|
||||||
|
tap.test('metric test fast 1 - minimal work', async () => {
|
||||||
|
expect(1).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test fast 2 - minimal work', async () => {
|
||||||
|
expect(2).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test fast 3 - minimal work', async () => {
|
||||||
|
expect(3).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to verify that failed tests still contribute to timing metrics
|
||||||
|
tap.test('metric test that fails - 60ms before failure', async (tools) => {
|
||||||
|
await tools.delayFor(60);
|
||||||
|
expect(true).toBeFalse(); // This will fail
|
||||||
|
});
|
||||||
|
|
||||||
|
// Describe block with timing to test aggregation
|
||||||
|
tap.describe('performance metrics in describe block', () => {
|
||||||
|
tap.test('described test 1 - 15ms', async (tools) => {
|
||||||
|
await tools.delayFor(15);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('described test 2 - 25ms', async (tools) => {
|
||||||
|
await tools.delayFor(25);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('described test 3 - 35ms', async (tools) => {
|
||||||
|
await tools.delayFor(35);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test timing with hooks
|
||||||
|
tap.describe('performance with hooks', () => {
|
||||||
|
let hookTime = 0;
|
||||||
|
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
// Hooks shouldn't count toward test time
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
hookTime += 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async () => {
|
||||||
|
// Hooks shouldn't count toward test time
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
hookTime += 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with hooks 1 - should only count test time', async (tools) => {
|
||||||
|
await tools.delayFor(30);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
// Test time should be ~30ms, not 50ms (including hooks)
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with hooks 2 - should only count test time', async (tools) => {
|
||||||
|
await tools.delayFor(40);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
// Test time should be ~40ms, not 60ms (including hooks)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel tests to verify timing is captured correctly
|
||||||
|
tap.describe('parallel timing verification', () => {
|
||||||
|
const startTimes: Map<string, number> = new Map();
|
||||||
|
const endTimes: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 1 - 80ms', async (tools) => {
|
||||||
|
startTimes.set('p1', Date.now());
|
||||||
|
await tools.delayFor(80);
|
||||||
|
endTimes.set('p1', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 2 - 90ms', async (tools) => {
|
||||||
|
startTimes.set('p2', Date.now());
|
||||||
|
await tools.delayFor(90);
|
||||||
|
endTimes.set('p2', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 3 - 100ms', async (tools) => {
|
||||||
|
startTimes.set('p3', Date.now());
|
||||||
|
await tools.delayFor(100);
|
||||||
|
endTimes.set('p3', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify parallel execution', async () => {
|
||||||
|
// This test runs after parallel tests
|
||||||
|
// Verify they actually ran in parallel by checking overlapping times
|
||||||
|
if (startTimes.size === 3 && endTimes.size === 3) {
|
||||||
|
const p1Start = startTimes.get('p1')!;
|
||||||
|
const p2Start = startTimes.get('p2')!;
|
||||||
|
const p3Start = startTimes.get('p3')!;
|
||||||
|
const p1End = endTimes.get('p1')!;
|
||||||
|
const p2End = endTimes.get('p2')!;
|
||||||
|
const p3End = endTimes.get('p3')!;
|
||||||
|
|
||||||
|
// Start times should be very close (within 50ms)
|
||||||
|
expect(Math.abs(p1Start - p2Start)).toBeLessThan(50);
|
||||||
|
expect(Math.abs(p2Start - p3Start)).toBeLessThan(50);
|
||||||
|
|
||||||
|
// There should be overlap in execution
|
||||||
|
const p1Overlaps = p1Start < p2End && p1End > p2Start;
|
||||||
|
const p2Overlaps = p2Start < p3End && p2End > p3Start;
|
||||||
|
|
||||||
|
expect(p1Overlaps || p2Overlaps).toBeTrue();
|
||||||
|
} else {
|
||||||
|
// Skip verification if parallel tests didn't run yet
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to ensure average calculation handles mixed timing correctly
|
||||||
|
tap.test('final metrics test - 5ms minimal', async (tools) => {
|
||||||
|
await tools.delayFor(5);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n📊 Expected Performance Metrics Summary:');
|
||||||
|
console.log('- Tests include a mix of durations from <1ms to 200ms');
|
||||||
|
console.log('- Slowest test should be "metric test slowest" at ~200ms');
|
||||||
|
console.log('- Average should be calculated from individual test times');
|
||||||
|
console.log('- Failed test should still contribute its 60ms to timing');
|
||||||
|
console.log('- Parallel tests should show their individual times (80ms, 90ms, 100ms)');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
214
test/tapbundle/test.timing-edge-cases.ts
Normal file
214
test/tapbundle/test.timing-edge-cases.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
|
||||||
|
tap.test('ultra-fast test - should capture sub-millisecond timing', async () => {
|
||||||
|
// This test does almost nothing, should complete in < 1ms
|
||||||
|
const x = 1 + 1;
|
||||||
|
expect(x).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with exact 1ms delay', async (tools) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await tools.delayFor(1);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
// Should be at least 1ms but could be more due to event loop
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 10ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 100ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(100);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 250ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(250);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 500ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(500);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with variable processing time', async (tools) => {
|
||||||
|
// Simulate variable processing
|
||||||
|
const iterations = 1000000;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
sum += Math.sqrt(i);
|
||||||
|
}
|
||||||
|
expect(sum).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Add a small delay to ensure measurable time
|
||||||
|
await tools.delayFor(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with multiple async operations', async () => {
|
||||||
|
// Multiple promises in parallel
|
||||||
|
const results = await Promise.all([
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(1), 10)),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(2), 20)),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(3), 30))
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2, 3]);
|
||||||
|
// This should take at least 30ms (the longest delay)
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with synchronous heavy computation', async () => {
|
||||||
|
// Heavy synchronous computation
|
||||||
|
const fibonacci = (n: number): number => {
|
||||||
|
if (n <= 1) return n;
|
||||||
|
return fibonacci(n - 1) + fibonacci(n - 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate fibonacci(30) - should take measurable time
|
||||||
|
const result = fibonacci(30);
|
||||||
|
expect(result).toEqual(832040);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with retry to see if timing accumulates correctly
|
||||||
|
tap.retry(2).test('test with retry - fails first then passes', async (tools) => {
|
||||||
|
// Get or initialize retry count
|
||||||
|
const retryCount = tools.context.get('retryCount') || 0;
|
||||||
|
tools.context.set('retryCount', retryCount + 1);
|
||||||
|
|
||||||
|
await tools.delayFor(50);
|
||||||
|
|
||||||
|
if (retryCount === 0) {
|
||||||
|
throw new Error('First attempt fails');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(retryCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test timeout handling
|
||||||
|
tap.timeout(100).test('test with timeout - should complete just in time', async (tools) => {
|
||||||
|
await tools.delayFor(80); // Just under the timeout
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip test - should show 0ms
|
||||||
|
tap.skip.test('skipped test - should report 0ms', async (tools) => {
|
||||||
|
await tools.delayFor(1000); // This won't execute
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todo test - should show 0ms
|
||||||
|
tap.todo.test('todo test - should report 0ms', async (tools) => {
|
||||||
|
await tools.delayFor(1000); // This won't execute
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with skip inside
|
||||||
|
tap.test('test that skips conditionally - should show time until skip', async (tools) => {
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
const shouldSkip = true;
|
||||||
|
if (shouldSkip) {
|
||||||
|
tools.skip('Skipping after 25ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This won't execute
|
||||||
|
await tools.delayFor(1000);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with very precise timing
|
||||||
|
tap.test('test with precise timing measurements', async (tools) => {
|
||||||
|
const measurements: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
await tools.delayFor(10);
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const durationMs = Number(end - start) / 1_000_000;
|
||||||
|
measurements.push(durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All measurements should be at least 10ms
|
||||||
|
measurements.forEach(m => {
|
||||||
|
expect(m).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// But not too much more (accounting for timer precision)
|
||||||
|
measurements.forEach(m => {
|
||||||
|
expect(m).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that intentionally has 0 actual work
|
||||||
|
tap.test('empty test - absolute minimum execution time', async () => {
|
||||||
|
// Literally nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with promise that resolves immediately
|
||||||
|
tap.test('test with immediate promise resolution', async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with microtask queue
|
||||||
|
tap.test('test with microtask queue processing', async () => {
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
await Promise.resolve().then(() => {
|
||||||
|
value = 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
}).then(() => {
|
||||||
|
value = 2;
|
||||||
|
return Promise.resolve();
|
||||||
|
}).then(() => {
|
||||||
|
value = 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(value).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to verify timing accumulation in describe blocks
|
||||||
|
tap.describe('timing in describe blocks', () => {
|
||||||
|
let startTime: number;
|
||||||
|
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
startTime = Date.now();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('first test in describe', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('second test in describe', async (tools) => {
|
||||||
|
await tools.delayFor(20);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel tests to see timing differences
|
||||||
|
tap.testParallel('parallel test 1 - 100ms', async (tools) => {
|
||||||
|
await tools.delayFor(100);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel test 2 - 50ms', async (tools) => {
|
||||||
|
await tools.delayFor(50);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel test 3 - 150ms', async (tools) => {
|
||||||
|
await tools.delayFor(150);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
204
test/tapbundle/test.timing-protocol.ts
Normal file
204
test/tapbundle/test.timing-protocol.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
import { ProtocolParser, ProtocolEmitter } from '../../ts_tapbundle_protocol/index.js';
|
||||||
|
|
||||||
|
// Test the protocol's ability to emit and parse timing metadata
|
||||||
|
tap.test('protocol should correctly emit timing metadata', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'test with timing',
|
||||||
|
metadata: {
|
||||||
|
time: 123
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
|
||||||
|
// Should have inline timing metadata
|
||||||
|
expect(lines.length).toEqual(1);
|
||||||
|
expect(lines[0]).toInclude('⟦TSTEST:time:123⟧');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should correctly parse timing metadata', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - test with timing ⟦TSTEST:time:456⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
expect(messages.length).toEqual(1);
|
||||||
|
expect(messages[0].type).toEqual('test');
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata).toBeDefined();
|
||||||
|
expect(content.metadata.time).toEqual(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle 0ms timing', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - ultra fast test ⟦TSTEST:time:0⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata.time).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle large timing values', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - slow test ⟦TSTEST:time:999999⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata.time).toEqual(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle timing with other metadata', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'complex test',
|
||||||
|
metadata: {
|
||||||
|
time: 789,
|
||||||
|
file: 'test.ts',
|
||||||
|
tags: ['slow', 'integration']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
|
||||||
|
// Should use block metadata format for complex metadata
|
||||||
|
expect(lines.length).toBeGreaterThan(1);
|
||||||
|
expect(lines[1]).toInclude('META:');
|
||||||
|
expect(lines[1]).toInclude('"time":789');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should parse timing from block metadata', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'ok 1 - complex test',
|
||||||
|
'⟦TSTEST:META:{"time":321,"file":"test.ts"}⟧'
|
||||||
|
];
|
||||||
|
|
||||||
|
let testResult: any;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
if (messages.length > 0 && messages[0].type === 'test') {
|
||||||
|
testResult = messages[0].content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testResult).toBeDefined();
|
||||||
|
expect(testResult.metadata).toBeUndefined(); // Metadata comes separately in block format
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('timing for skipped tests should be 0 or missing', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'skipped test',
|
||||||
|
directive: {
|
||||||
|
type: 'skip' as const,
|
||||||
|
reason: 'Not ready'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
time: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
expect(lines[0]).toInclude('# SKIP');
|
||||||
|
|
||||||
|
// If time is 0, it might be included or omitted
|
||||||
|
if (lines[0].includes('⟦TSTEST:')) {
|
||||||
|
expect(lines[0]).toInclude('time:0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle fractional milliseconds', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
// Even though we use integers, test that protocol handles them correctly
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'precise test',
|
||||||
|
metadata: {
|
||||||
|
time: 123 // Protocol uses integers for milliseconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
expect(lines[0]).toInclude('time:123');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle timing in retry scenarios', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'retry test',
|
||||||
|
metadata: {
|
||||||
|
time: 200,
|
||||||
|
retry: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
// Should include both time and retry
|
||||||
|
expect(lines[0]).toMatch(/time:200.*retry:2|retry:2.*time:200/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test actual timing capture
|
||||||
|
tap.test('HrtMeasurement should capture accurate timing', async (tools) => {
|
||||||
|
// Import HrtMeasurement
|
||||||
|
const { HrtMeasurement } = await import('@push.rocks/smarttime');
|
||||||
|
|
||||||
|
const measurement = new HrtMeasurement();
|
||||||
|
measurement.start();
|
||||||
|
|
||||||
|
await tools.delayFor(50);
|
||||||
|
|
||||||
|
measurement.stop();
|
||||||
|
|
||||||
|
// Should be at least 50ms
|
||||||
|
expect(measurement.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
// But not too much more (allow for some overhead)
|
||||||
|
expect(measurement.milliSeconds).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multiple timing measurements should be independent', async (tools) => {
|
||||||
|
const { HrtMeasurement } = await import('@push.rocks/smarttime');
|
||||||
|
|
||||||
|
const measurement1 = new HrtMeasurement();
|
||||||
|
const measurement2 = new HrtMeasurement();
|
||||||
|
|
||||||
|
measurement1.start();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
measurement2.start();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
measurement1.stop();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
measurement2.stop();
|
||||||
|
|
||||||
|
// measurement1 should be ~50ms (25ms + 25ms)
|
||||||
|
expect(measurement1.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(measurement1.milliSeconds).toBeLessThan(70);
|
||||||
|
|
||||||
|
// measurement2 should be ~50ms (25ms + 25ms)
|
||||||
|
expect(measurement2.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(measurement2.milliSeconds).toBeLessThan(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tstest',
|
name: '@git.zone/tstest',
|
||||||
version: '1.9.3',
|
version: '2.3.0',
|
||||||
description: 'a test utility to run tests that match test/**/*.ts'
|
description: 'a test utility to run tests that match test/**/*.ts'
|
||||||
}
|
}
|
||||||
|
140
ts/index.ts
140
ts/index.ts
@ -8,17 +8,68 @@ export enum TestExecutionMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const runCli = async () => {
|
export const runCli = async () => {
|
||||||
|
// Check if we're using global tstest in the tstest project itself
|
||||||
|
try {
|
||||||
|
const packageJsonPath = `${process.cwd()}/package.json`;
|
||||||
|
const fs = await import('fs');
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
if (packageJson.name === '@git.zone/tstest') {
|
||||||
|
// Check if we're running from a global installation
|
||||||
|
const execPath = process.argv[1];
|
||||||
|
// Debug: log the paths (uncomment for debugging)
|
||||||
|
// console.log('DEBUG: Checking global tstest usage...');
|
||||||
|
// console.log('execPath:', execPath);
|
||||||
|
// console.log('cwd:', process.cwd());
|
||||||
|
// console.log('process.argv:', process.argv);
|
||||||
|
|
||||||
|
// Check if this is running from global installation
|
||||||
|
const isLocalCli = execPath.includes(process.cwd());
|
||||||
|
const isGlobalPnpm = process.argv.some(arg => arg.includes('.pnpm') && !arg.includes(process.cwd()));
|
||||||
|
const isGlobalNpm = process.argv.some(arg => arg.includes('npm/node_modules') && !arg.includes(process.cwd()));
|
||||||
|
|
||||||
|
if (!isLocalCli && (isGlobalPnpm || isGlobalNpm || !execPath.includes('node_modules'))) {
|
||||||
|
console.error('\n⚠️ WARNING: You are using a globally installed tstest in the tstest project itself!');
|
||||||
|
console.error(' This means you are NOT testing your local changes.');
|
||||||
|
console.error(' Please use one of these commands instead:');
|
||||||
|
console.error(' • node cli.js <test-path>');
|
||||||
|
console.error(' • pnpm test <test-path>');
|
||||||
|
console.error(' • ./cli.js <test-path> (if executable)\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore any errors in this check
|
||||||
|
}
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const logOptions: LogOptions = {};
|
const logOptions: LogOptions = {};
|
||||||
let testPath: string | null = null;
|
let testPath: string | null = null;
|
||||||
let tags: string[] = [];
|
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
|
// Parse options
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
|
|
||||||
switch (arg) {
|
switch (arg) {
|
||||||
|
case '--version':
|
||||||
|
// Get version from package.json
|
||||||
|
try {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const packagePath = new URL('../package.json', import.meta.url).pathname;
|
||||||
|
const packageData = JSON.parse(await fs.promises.readFile(packagePath, 'utf8'));
|
||||||
|
console.log(`tstest version ${packageData.version}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('tstest version unknown');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
case '--quiet':
|
case '--quiet':
|
||||||
case '-q':
|
case '-q':
|
||||||
logOptions.quiet = true;
|
logOptions.quiet = true;
|
||||||
@ -42,6 +93,57 @@ export const runCli = async () => {
|
|||||||
tags = args[++i].split(',');
|
tags = args[++i].split(',');
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
if (!arg.startsWith('-')) {
|
if (!arg.startsWith('-')) {
|
||||||
testPath = arg;
|
testPath = arg;
|
||||||
@ -49,16 +151,28 @@ 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) {
|
if (!testPath) {
|
||||||
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
|
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
|
||||||
console.error('\nUsage: tstest <path> [options]');
|
console.error('\nUsage: tstest <path> [options]');
|
||||||
console.error('\nOptions:');
|
console.error('\nOptions:');
|
||||||
console.error(' --quiet, -q Minimal output');
|
console.error(' --version Show version information');
|
||||||
console.error(' --verbose, -v Verbose output');
|
console.error(' --quiet, -q Minimal output');
|
||||||
console.error(' --no-color Disable colored output');
|
console.error(' --verbose, -v Verbose output');
|
||||||
console.error(' --json Output results as JSON');
|
console.error(' --no-color Disable colored output');
|
||||||
console.error(' --logfile Write logs to .nogit/testlogs/[testfile].log');
|
console.error(' --json Output results as JSON');
|
||||||
console.error(' --tags Run only tests with specified tags (comma-separated)');
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +187,16 @@ export const runCli = async () => {
|
|||||||
executionMode = TestExecutionMode.DIRECTORY;
|
executionMode = TestExecutionMode.DIRECTORY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags);
|
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile, timeoutSeconds);
|
||||||
await tsTestInstance.run();
|
|
||||||
|
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 {
|
export class TapCombinator {
|
||||||
tapParserStore: TapParser[] = [];
|
tapParserStore: TapParser[] = [];
|
||||||
|
skippedFiles: string[] = [];
|
||||||
private logger: TsTestLogger;
|
private logger: TsTestLogger;
|
||||||
|
|
||||||
constructor(logger: TsTestLogger) {
|
constructor(logger: TsTestLogger) {
|
||||||
@ -19,10 +20,14 @@ export class TapCombinator {
|
|||||||
addTapParser(tapParserArg: TapParser) {
|
addTapParser(tapParserArg: TapParser) {
|
||||||
this.tapParserStore.push(tapParserArg);
|
this.tapParserStore.push(tapParserArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addSkippedFile(filename: string) {
|
||||||
|
this.skippedFiles.push(filename);
|
||||||
|
}
|
||||||
|
|
||||||
evaluate() {
|
evaluate() {
|
||||||
// Call the logger's summary method
|
// Call the logger's summary method with skipped files
|
||||||
this.logger.summary();
|
this.logger.summary(this.skippedFiles);
|
||||||
|
|
||||||
// Check for failures
|
// Check for failures
|
||||||
let failGlobal = false;
|
let failGlobal = false;
|
||||||
|
@ -8,28 +8,57 @@ import * as plugins from './tstest.plugins.js';
|
|||||||
import { TapTestResult } from './tstest.classes.tap.testresult.js';
|
import { TapTestResult } from './tstest.classes.tap.testresult.js';
|
||||||
import * as logPrefixes from './tstest.logprefixes.js';
|
import * as logPrefixes from './tstest.logprefixes.js';
|
||||||
import { TsTestLogger } from './tstest.logging.js';
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
|
import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
import type { IProtocolMessage, ITestResult, IPlanLine, IErrorBlock, ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
|
||||||
export class TapParser {
|
export class TapParser {
|
||||||
testStore: TapTestResult[] = [];
|
testStore: TapTestResult[] = [];
|
||||||
|
|
||||||
expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/;
|
expectedTests: number = 0;
|
||||||
expectedTests: number;
|
receivedTests: number = 0;
|
||||||
receivedTests: number;
|
|
||||||
|
|
||||||
testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*?)(\s#\s(.*))?$/;
|
|
||||||
activeTapTestResult: TapTestResult;
|
activeTapTestResult: TapTestResult;
|
||||||
collectingErrorDetails: boolean = false;
|
|
||||||
currentTestError: string[] = [];
|
|
||||||
|
|
||||||
pretaskRegex = /^::__PRETASK:(.*)$/;
|
|
||||||
|
|
||||||
private logger: TsTestLogger;
|
private logger: TsTestLogger;
|
||||||
|
private protocolParser: ProtocolParser;
|
||||||
|
private protocolVersion: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the constructor for TapParser
|
* the constructor for TapParser
|
||||||
*/
|
*/
|
||||||
constructor(public fileName: string, logger?: TsTestLogger) {
|
constructor(public fileName: string, logger?: TsTestLogger) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
this.protocolParser = new ProtocolParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
private _getNewTapTestResult() {
|
||||||
@ -45,142 +74,299 @@ export class TapParser {
|
|||||||
logLineArray.pop();
|
logLineArray.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets parse the log information
|
// Process each line through the protocol parser
|
||||||
for (const logLine of logLineArray) {
|
for (const logLine of logLineArray) {
|
||||||
let logLineIsTapProtocol = false;
|
const messages = this.protocolParser.parseLine(logLine);
|
||||||
if (!this.expectedTests && this.expectedTestsRegex.test(logLine)) {
|
|
||||||
logLineIsTapProtocol = true;
|
if (messages.length > 0) {
|
||||||
const regexResult = this.expectedTestsRegex.exec(logLine);
|
// Handle protocol messages
|
||||||
this.expectedTests = parseInt(regexResult[2]);
|
for (const message of messages) {
|
||||||
if (this.logger) {
|
this._handleProtocolMessage(message, logLine);
|
||||||
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// initiating first TapResult
|
// Not a protocol message, handle as console output
|
||||||
this._getNewTapTestResult();
|
|
||||||
} else if (this.pretaskRegex.test(logLine)) {
|
|
||||||
logLineIsTapProtocol = true;
|
|
||||||
const pretaskContentMatch = this.pretaskRegex.exec(logLine);
|
|
||||||
if (pretaskContentMatch && pretaskContentMatch[1]) {
|
|
||||||
if (this.logger) {
|
|
||||||
this.logger.tapOutput(`Pretask -> ${pretaskContentMatch[1]}: Success.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (this.testStatusRegex.test(logLine)) {
|
|
||||||
logLineIsTapProtocol = true;
|
|
||||||
const regexResult = this.testStatusRegex.exec(logLine);
|
|
||||||
const testId = parseInt(regexResult[2]);
|
|
||||||
const testOk = (() => {
|
|
||||||
if (regexResult[1] === 'ok') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const testSubject = regexResult[3].trim();
|
|
||||||
const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason"
|
|
||||||
|
|
||||||
let testDuration = 0;
|
|
||||||
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) {
|
|
||||||
if (this.activeTapTestResult) {
|
if (this.activeTapTestResult) {
|
||||||
this.activeTapTestResult.addLogLine(logLine);
|
this.activeTapTestResult.addLogLine(logLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for snapshot communication
|
// Check for snapshot communication (legacy)
|
||||||
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||||
if (snapshotMatch) {
|
if (snapshotMatch) {
|
||||||
const base64Data = snapshotMatch[1];
|
const base64Data = snapshotMatch[1];
|
||||||
try {
|
try {
|
||||||
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
|
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
|
||||||
this.handleSnapshot(snapshotData);
|
this.handleSnapshot(snapshotData);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
|
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (this.logger) {
|
||||||
// Check if we're collecting error details
|
// This is console output from the test file
|
||||||
if (this.collectingErrorDetails) {
|
this.logger.testConsoleOutput(logLine);
|
||||||
// Check if this line is an error detail (starts with Error: or has stack trace characteristics)
|
|
||||||
if (logLine.trim().startsWith('Error:') || logLine.trim().match(/^\s*at\s/)) {
|
|
||||||
this.currentTestError.push(logLine);
|
|
||||||
} else if (this.currentTestError.length > 0) {
|
|
||||||
// End of error details, show the error
|
|
||||||
const errorMessage = this.currentTestError.join('\n');
|
|
||||||
if (this.logger) {
|
|
||||||
this.logger.testErrorDetails(errorMessage);
|
|
||||||
}
|
|
||||||
this.collectingErrorDetails = false;
|
|
||||||
this.currentTestError = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't output TAP error details as console output when we're collecting them
|
|
||||||
if (!this.collectingErrorDetails || (!logLine.trim().startsWith('Error:') && !logLine.trim().match(/^\s*at\s/))) {
|
|
||||||
if (this.logger) {
|
|
||||||
// This is console output from the test file, not TAP protocol
|
|
||||||
this.logger.testConsoleOutput(logLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.activeTapTestResult && this.activeTapTestResult.testSettled) {
|
private _handleProtocolMessage(message: IProtocolMessage, originalLine: string) {
|
||||||
// Ensure any pending error is shown before settling the test
|
switch (message.type) {
|
||||||
if (this.collectingErrorDetails && this.currentTestError.length > 0) {
|
case 'protocol':
|
||||||
const errorMessage = this.currentTestError.join('\n');
|
this.protocolVersion = message.content.version;
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.tapOutput(`Protocol version: ${this.protocolVersion}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'version':
|
||||||
|
// TAP version, we can ignore this
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
const plan = message.content as IPlanLine;
|
||||||
|
this.expectedTests = plan.end - plan.start + 1;
|
||||||
|
if (plan.skipAll) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.testErrorDetails(errorMessage);
|
this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`);
|
||||||
}
|
}
|
||||||
this.collectingErrorDetails = false;
|
} else {
|
||||||
this.currentTestError = [];
|
if (this.logger) {
|
||||||
|
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Initialize first TapResult
|
||||||
|
this._getNewTapTestResult();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test':
|
||||||
|
const testResult = message.content as ITestResult;
|
||||||
|
|
||||||
|
// Update active test result
|
||||||
|
this.activeTapTestResult.setTestResult(testResult.ok);
|
||||||
|
|
||||||
|
// Extract test duration from metadata
|
||||||
|
let testDuration = 0;
|
||||||
|
if (testResult.metadata?.time) {
|
||||||
|
testDuration = testResult.metadata.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log test result
|
||||||
|
if (this.logger) {
|
||||||
|
if (testResult.ok) {
|
||||||
|
this.logger.testResult(testResult.description, true, testDuration);
|
||||||
|
} else {
|
||||||
|
this.logger.testResult(testResult.description, false, testDuration);
|
||||||
|
|
||||||
|
// If there's error metadata, show it
|
||||||
|
if (testResult.metadata?.error) {
|
||||||
|
const error = testResult.metadata.error;
|
||||||
|
let errorDetails = error.message;
|
||||||
|
if (error.stack) {
|
||||||
|
errorDetails = error.stack;
|
||||||
|
}
|
||||||
|
this.logger.testErrorDetails(errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle directives (skip/todo)
|
||||||
|
if (testResult.directive) {
|
||||||
|
if (this.logger) {
|
||||||
|
if (testResult.directive.type === 'skip') {
|
||||||
|
this.logger.testConsoleOutput(`Test skipped: ${testResult.directive.reason || 'No reason given'}`);
|
||||||
|
} else if (testResult.directive.type === 'todo') {
|
||||||
|
this.logger.testConsoleOutput(`Test todo: ${testResult.directive.reason || 'No reason given'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark test as settled and move to next
|
||||||
|
this.activeTapTestResult.testSettled = true;
|
||||||
this.testStore.push(this.activeTapTestResult);
|
this.testStore.push(this.activeTapTestResult);
|
||||||
this._getNewTapTestResult();
|
this._getNewTapTestResult();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'comment':
|
||||||
|
if (this.logger) {
|
||||||
|
// Check if it's a pretask comment
|
||||||
|
const pretaskMatch = message.content.match(/^Pretask -> (.+): Success\.$/);
|
||||||
|
if (pretaskMatch) {
|
||||||
|
this.logger.tapOutput(message.content);
|
||||||
|
} else {
|
||||||
|
this.logger.testConsoleOutput(message.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bailout':
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.error(`Bail out! ${message.content}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
const errorBlock = message.content as IErrorBlock;
|
||||||
|
if (this.logger && errorBlock.error) {
|
||||||
|
let errorDetails = errorBlock.error.message;
|
||||||
|
if (errorBlock.error.stack) {
|
||||||
|
errorDetails = errorBlock.error.stack;
|
||||||
|
}
|
||||||
|
this.logger.testErrorDetails(errorDetails);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'snapshot':
|
||||||
|
// Handle new protocol snapshot format
|
||||||
|
const snapshot = message.content;
|
||||||
|
this.handleSnapshot({
|
||||||
|
path: snapshot.name,
|
||||||
|
content: typeof snapshot.content === 'string' ? snapshot.content : JSON.stringify(snapshot.content),
|
||||||
|
action: 'compare' // Default action
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'event':
|
||||||
|
const event = message.content as ITestEvent;
|
||||||
|
this._handleTestEvent(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTestEvent(event: ITestEvent) {
|
||||||
|
if (!this.logger) return;
|
||||||
|
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'test:queued':
|
||||||
|
// We can track queued tests if needed
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test:started':
|
||||||
|
this.logger.testConsoleOutput(cs(`Test starting: ${event.data.description}`, 'cyan'));
|
||||||
|
if (event.data.retry) {
|
||||||
|
this.logger.testConsoleOutput(cs(` Retry attempt ${event.data.retry}`, 'orange'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test:progress':
|
||||||
|
if (event.data.progress !== undefined) {
|
||||||
|
this.logger.testConsoleOutput(cs(` Progress: ${event.data.progress}%`, 'cyan'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test:completed':
|
||||||
|
// Test completion is already handled by the test result
|
||||||
|
// This event provides additional timing info if needed
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'suite:started':
|
||||||
|
this.logger.testConsoleOutput(cs(`\nSuite: ${event.data.suiteName}`, 'blue'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'suite:completed':
|
||||||
|
this.logger.testConsoleOutput(cs(`Suite completed: ${event.data.suiteName}\n`, 'blue'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hook:started':
|
||||||
|
this.logger.testConsoleOutput(cs(` Hook: ${event.data.hookName}`, 'cyan'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hook:completed':
|
||||||
|
// Silent unless there's an error
|
||||||
|
if (event.data.error) {
|
||||||
|
this.logger.testConsoleOutput(cs(` Hook failed: ${event.data.hookName}`, 'red'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'assertion:failed':
|
||||||
|
// Enhanced assertion failure with diff
|
||||||
|
if (event.data.error) {
|
||||||
|
this._displayAssertionError(event.data.error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _displayAssertionError(error: any) {
|
||||||
|
if (!this.logger) return;
|
||||||
|
|
||||||
|
// Display error message
|
||||||
|
if (error.message) {
|
||||||
|
this.logger.testErrorDetails(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display visual diff if available
|
||||||
|
if (error.diff) {
|
||||||
|
this._displayDiff(error.diff, error.expected, error.actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _displayDiff(diff: any, expected: any, actual: any) {
|
||||||
|
if (!this.logger) return;
|
||||||
|
|
||||||
|
this.logger.testConsoleOutput(cs('\n Diff:', 'cyan'));
|
||||||
|
|
||||||
|
switch (diff.type) {
|
||||||
|
case 'string':
|
||||||
|
this._displayStringDiff(diff.changes);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
this._displayObjectDiff(diff.changes, expected, actual);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
this._displayArrayDiff(diff.changes, expected, actual);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'primitive':
|
||||||
|
this._displayPrimitiveDiff(diff.changes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _displayStringDiff(changes: any[]) {
|
||||||
|
for (const change of changes) {
|
||||||
|
const linePrefix = ` Line ${change.line + 1}: `;
|
||||||
|
if (change.type === 'add') {
|
||||||
|
this.logger.testConsoleOutput(cs(`${linePrefix}+ ${change.content}`, 'green'));
|
||||||
|
} else if (change.type === 'remove') {
|
||||||
|
this.logger.testConsoleOutput(cs(`${linePrefix}- ${change.content}`, 'red'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _displayObjectDiff(changes: any[], expected: any, actual: any) {
|
||||||
|
this.logger.testConsoleOutput(cs(' Expected:', 'red'));
|
||||||
|
this.logger.testConsoleOutput(` ${JSON.stringify(expected, null, 2)}`);
|
||||||
|
this.logger.testConsoleOutput(cs(' Actual:', 'green'));
|
||||||
|
this.logger.testConsoleOutput(` ${JSON.stringify(actual, null, 2)}`);
|
||||||
|
|
||||||
|
this.logger.testConsoleOutput(cs('\n Changes:', 'cyan'));
|
||||||
|
for (const change of changes) {
|
||||||
|
const path = change.path.join('.');
|
||||||
|
if (change.type === 'add') {
|
||||||
|
this.logger.testConsoleOutput(cs(` + ${path}: ${JSON.stringify(change.newValue)}`, 'green'));
|
||||||
|
} else if (change.type === 'remove') {
|
||||||
|
this.logger.testConsoleOutput(cs(` - ${path}: ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||||
|
} else if (change.type === 'modify') {
|
||||||
|
this.logger.testConsoleOutput(cs(` ~ ${path}:`, 'cyan'));
|
||||||
|
this.logger.testConsoleOutput(cs(` - ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||||
|
this.logger.testConsoleOutput(cs(` + ${JSON.stringify(change.newValue)}`, 'green'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _displayArrayDiff(changes: any[], expected: any[], actual: any[]) {
|
||||||
|
this._displayObjectDiff(changes, expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _displayPrimitiveDiff(changes: any[]) {
|
||||||
|
const change = changes[0];
|
||||||
|
if (change) {
|
||||||
|
this.logger.testConsoleOutput(cs(` Expected: ${JSON.stringify(change.oldValue)}`, 'red'));
|
||||||
|
this.logger.testConsoleOutput(cs(` Actual: ${JSON.stringify(change.newValue)}`, 'green'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns all tests that are not completed
|
* returns all tests that are not completed
|
||||||
@ -305,13 +491,16 @@ export class TapParser {
|
|||||||
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
|
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.expectedTests) {
|
if (!this.expectedTests && this.receivedTests === 0) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.error('No tests were defined. Therefore the testfile failed!');
|
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) {
|
} else if (this.expectedTests !== this.receivedTests) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
|
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) {
|
} else if (this.getErrorTests().length === 0) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
@ -325,4 +514,4 @@ export class TapParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import * as plugins from './tstest.plugins.js';
|
import * as plugins from './tstest.plugins.js';
|
||||||
import * as paths from './tstest.paths.js';
|
import * as paths from './tstest.paths.js';
|
||||||
import * as logPrefixes from './tstest.logprefixes.js';
|
|
||||||
|
|
||||||
import { coloredString as cs } from '@push.rocks/consolecolor';
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
|
||||||
@ -16,6 +15,9 @@ export class TsTest {
|
|||||||
public executionMode: TestExecutionMode;
|
public executionMode: TestExecutionMode;
|
||||||
public logger: TsTestLogger;
|
public logger: TsTestLogger;
|
||||||
public filterTags: string[];
|
public filterTags: string[];
|
||||||
|
public startFromFile: number | null;
|
||||||
|
public stopAtFile: number | null;
|
||||||
|
public timeoutSeconds: number | null;
|
||||||
|
|
||||||
public smartshellInstance = new plugins.smartshell.Smartshell({
|
public smartshellInstance = new plugins.smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
@ -26,18 +28,26 @@ export class TsTest {
|
|||||||
|
|
||||||
public tsbundleInstance = new plugins.tsbundle.TsBundle();
|
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.executionMode = executionModeArg;
|
||||||
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
||||||
this.logger = new TsTestLogger(logOptions);
|
this.logger = new TsTestLogger(logOptions);
|
||||||
this.filterTags = tags;
|
this.filterTags = tags;
|
||||||
|
this.startFromFile = startFromFile;
|
||||||
|
this.stopAtFile = stopAtFile;
|
||||||
|
this.timeoutSeconds = timeoutSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
|
// Move previous log files if --logfile option is used
|
||||||
|
if (this.logger.options.logFile) {
|
||||||
|
await this.movePreviousLogFiles();
|
||||||
|
}
|
||||||
|
|
||||||
const testGroups = await this.testDir.getTestFileGroups();
|
const testGroups = await this.testDir.getTestFileGroups();
|
||||||
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
||||||
|
|
||||||
// Log test discovery
|
// Log test discovery - always show full count
|
||||||
this.logger.testDiscovery(
|
this.logger.testDiscovery(
|
||||||
allFiles.length,
|
allFiles.length,
|
||||||
this.testDir.testPath,
|
this.testDir.testPath,
|
||||||
@ -50,7 +60,7 @@ export class TsTest {
|
|||||||
// Execute serial tests first
|
// Execute serial tests first
|
||||||
for (const fileNameArg of testGroups.serial) {
|
for (const fileNameArg of testGroups.serial) {
|
||||||
fileIndex++;
|
fileIndex++;
|
||||||
await this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute parallel groups sequentially
|
// Execute parallel groups sequentially
|
||||||
@ -64,7 +74,7 @@ export class TsTest {
|
|||||||
// Run all tests in this group in parallel
|
// Run all tests in this group in parallel
|
||||||
const parallelPromises = groupFiles.map(async (fileNameArg) => {
|
const parallelPromises = groupFiles.map(async (fileNameArg) => {
|
||||||
fileIndex++;
|
fileIndex++;
|
||||||
return this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(parallelPromises);
|
await Promise.all(parallelPromises);
|
||||||
@ -75,6 +85,95 @@ export class TsTest {
|
|||||||
tapCombinator.evaluate();
|
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) {
|
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case process.env.CI && fileNameArg.includes('.nonci.'):
|
case process.env.CI && fileNameArg.includes('.nonci.'):
|
||||||
@ -117,10 +216,103 @@ export class TsTest {
|
|||||||
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(
|
// Check for 00init.ts file in test directory
|
||||||
`tsrun ${fileNameArg}${tsrunOptions}`
|
const testDir = plugins.path.dirname(fileNameArg);
|
||||||
);
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||||
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start warning timer if no timeout was specified
|
||||||
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${fileNameArg}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
return tapParser;
|
return tapParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +337,7 @@ export class TsTest {
|
|||||||
});
|
});
|
||||||
server.addRoute(
|
server.addRoute(
|
||||||
'/test',
|
'/test',
|
||||||
new plugins.typedserver.servertools.Handler('GET', async (req, res) => {
|
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
|
||||||
res.type('.html');
|
res.type('.html');
|
||||||
res.write(`
|
res.write(`
|
||||||
<html>
|
<html>
|
||||||
@ -178,9 +370,10 @@ export class TsTest {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// lets do the browser bit
|
// lets do the browser bit with timeout handling
|
||||||
await this.smartbrowserInstance.start();
|
await this.smartbrowserInstance.start();
|
||||||
const evaluation = await this.smartbrowserInstance.evaluateOnPage(
|
|
||||||
|
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
|
||||||
`http://localhost:3007/test?bundleName=${bundleFileName}`,
|
`http://localhost:3007/test?bundleName=${bundleFileName}`,
|
||||||
async () => {
|
async () => {
|
||||||
// lets enable real time comms
|
// lets enable real time comms
|
||||||
@ -193,12 +386,12 @@ export class TsTest {
|
|||||||
const originalError = console.error;
|
const originalError = console.error;
|
||||||
|
|
||||||
// Override console methods to capture the logs
|
// Override console methods to capture the logs
|
||||||
console.log = (...args) => {
|
console.log = (...args: any[]) => {
|
||||||
logStore.push(args.join(' '));
|
logStore.push(args.join(' '));
|
||||||
ws.send(args.join(' '));
|
ws.send(args.join(' '));
|
||||||
originalLog(...args);
|
originalLog(...args);
|
||||||
};
|
};
|
||||||
console.error = (...args) => {
|
console.error = (...args: any[]) => {
|
||||||
logStore.push(args.join(' '));
|
logStore.push(args.join(' '));
|
||||||
ws.send(args.join(' '));
|
ws.send(args.join(' '));
|
||||||
originalError(...args);
|
originalError(...args);
|
||||||
@ -237,16 +430,127 @@ export class TsTest {
|
|||||||
return logStore.join('\n');
|
return logStore.join('\n');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await this.smartbrowserInstance.stop();
|
|
||||||
await server.stop();
|
// Start warning timer if no timeout was specified
|
||||||
wss.close();
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${fileNameArg}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// Handle timeout error
|
||||||
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await evaluatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
console.log(
|
||||||
`${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.`
|
`${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();
|
await tapParser.evaluateFinalResult();
|
||||||
return tapParser;
|
return tapParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async runInDeno() {}
|
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 (plugins.smartfile.fs.isDirectorySync(errDir)) {
|
||||||
|
plugins.smartfile.fs.removeSync(errDir);
|
||||||
|
}
|
||||||
|
if (plugins.smartfile.fs.isDirectorySync(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;
|
totalTests: number;
|
||||||
totalPassed: number;
|
totalPassed: number;
|
||||||
totalFailed: number;
|
totalFailed: number;
|
||||||
|
totalSkipped: number;
|
||||||
totalDuration: number;
|
totalDuration: number;
|
||||||
fileResults: TestFileResult[];
|
fileResults: TestFileResult[];
|
||||||
|
skippedFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TsTestLogger {
|
export class TsTestLogger {
|
||||||
private options: LogOptions;
|
public readonly options: LogOptions;
|
||||||
private startTime: number;
|
private startTime: number;
|
||||||
private fileResults: TestFileResult[] = [];
|
private fileResults: TestFileResult[] = [];
|
||||||
private currentFileResult: TestFileResult | null = null;
|
private currentFileResult: TestFileResult | null = null;
|
||||||
@ -240,9 +242,49 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
if (!this.options.quiet) {
|
if (!this.options.quiet) {
|
||||||
const total = passed + failed;
|
const total = passed + failed;
|
||||||
const status = failed === 0 ? 'PASSED' : 'FAILED';
|
if (failed === 0) {
|
||||||
const color = failed === 0 ? 'green' : 'red';
|
this.log(this.format(` Summary: ${passed}/${total} PASSED`, 'green'));
|
||||||
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
|
} else {
|
||||||
|
this.log(this.format(` Summary: ${passed} passed, ${failed} failed of ${total} tests`, 'red'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Clear the current test log file reference only if using --logfile
|
||||||
@ -252,7 +294,7 @@ export class TsTestLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TAP output forwarding (for TAP protocol messages)
|
// TAP output forwarding (for TAP protocol messages)
|
||||||
tapOutput(message: string, isError: boolean = false) {
|
tapOutput(message: string, _isError: boolean = false) {
|
||||||
if (this.options.json) return;
|
if (this.options.json) return;
|
||||||
|
|
||||||
// Never show raw TAP protocol messages in console
|
// Never show raw TAP protocol messages in console
|
||||||
@ -282,6 +324,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
|
// Browser console
|
||||||
browserConsole(message: string, level: string = 'log') {
|
browserConsole(message: string, level: string = 'log') {
|
||||||
if (this.options.json) {
|
if (this.options.json) {
|
||||||
@ -317,15 +372,17 @@ export class TsTestLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Final summary
|
// Final summary
|
||||||
summary() {
|
summary(skippedFiles: string[] = []) {
|
||||||
const totalDuration = Date.now() - this.startTime;
|
const totalDuration = Date.now() - this.startTime;
|
||||||
const summary: TestSummary = {
|
const summary: TestSummary = {
|
||||||
totalFiles: this.fileResults.length,
|
totalFiles: this.fileResults.length + skippedFiles.length,
|
||||||
totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
|
totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
|
||||||
totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
|
totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
|
||||||
totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
|
totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
|
||||||
|
totalSkipped: skippedFiles.length,
|
||||||
totalDuration,
|
totalDuration,
|
||||||
fileResults: this.fileResults
|
fileResults: this.fileResults,
|
||||||
|
skippedFiles
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.options.json) {
|
if (this.options.json) {
|
||||||
@ -335,7 +392,11 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
if (this.options.quiet) {
|
if (this.options.quiet) {
|
||||||
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
|
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
|
||||||
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
|
if (summary.totalFailed === 0) {
|
||||||
|
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
|
||||||
|
} else {
|
||||||
|
this.log(`\nSummary: ${summary.totalPassed} passed, ${summary.totalFailed} failed of ${summary.totalTests} tests | ${totalDuration}ms | ${status}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,6 +407,9 @@ export class TsTestLogger {
|
|||||||
this.log(this.format(`│ Total Tests: ${summary.totalTests.toString().padStart(14)} │`, 'white'));
|
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(`│ Passed: ${summary.totalPassed.toString().padStart(14)} │`, 'green'));
|
||||||
this.log(this.format(`│ Failed: ${summary.totalFailed.toString().padStart(14)} │`, summary.totalFailed > 0 ? 'red' : '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(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white'));
|
||||||
this.log(this.format('└────────────────────────────────┘', 'dim'));
|
this.log(this.format('└────────────────────────────────┘', 'dim'));
|
||||||
|
|
||||||
@ -367,15 +431,23 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
// Performance metrics
|
// Performance metrics
|
||||||
if (this.options.verbose) {
|
if (this.options.verbose) {
|
||||||
const avgDuration = Math.round(totalDuration / summary.totalTests);
|
// Calculate metrics based on actual test durations
|
||||||
const slowestTest = this.fileResults
|
const allTests = this.fileResults.flatMap(r => r.tests);
|
||||||
.flatMap(r => r.tests)
|
const testDurations = allTests.map(t => t.duration);
|
||||||
.sort((a, b) => b.duration - a.duration)[0];
|
const sumOfTestDurations = testDurations.reduce((sum, d) => sum + d, 0);
|
||||||
|
const avgTestDuration = allTests.length > 0 ? Math.round(sumOfTestDurations / allTests.length) : 0;
|
||||||
|
|
||||||
|
// Find slowest test (exclude 0ms durations unless all are 0)
|
||||||
|
const nonZeroDurations = allTests.filter(t => t.duration > 0);
|
||||||
|
const testsToSort = nonZeroDurations.length > 0 ? nonZeroDurations : allTests;
|
||||||
|
const slowestTest = testsToSort.sort((a, b) => b.duration - a.duration)[0];
|
||||||
|
|
||||||
this.log(this.format('\n⏱️ Performance Metrics:', 'cyan'));
|
this.log(this.format('\n⏱️ Performance Metrics:', 'cyan'));
|
||||||
this.log(this.format(` Average per test: ${avgDuration}ms`, 'white'));
|
this.log(this.format(` Average per test: ${avgTestDuration}ms`, 'white'));
|
||||||
if (slowestTest) {
|
if (slowestTest && slowestTest.duration > 0) {
|
||||||
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'yellow'));
|
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'orange'));
|
||||||
|
} else if (allTests.length > 0) {
|
||||||
|
this.log(this.format(` All tests completed in <1ms`, 'dim'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,6 +457,20 @@ export class TsTestLogger {
|
|||||||
this.log(this.format(`\n${status}`, statusColor));
|
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 display
|
||||||
error(message: string, file?: string, stack?: string) {
|
error(message: string, file?: string, stack?: string) {
|
||||||
if (this.options.json) {
|
if (this.options.json) {
|
||||||
@ -404,4 +490,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
|
// @push.rocks scope
|
||||||
import * as consolecolor from '@push.rocks/consolecolor';
|
import * as consolecolor from '@push.rocks/consolecolor';
|
||||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||||
|
import * as smartchok from '@push.rocks/smartchok';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
@ -23,6 +24,7 @@ import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
|||||||
export {
|
export {
|
||||||
consolecolor,
|
consolecolor,
|
||||||
smartbrowser,
|
smartbrowser,
|
||||||
|
smartchok,
|
||||||
smartdelay,
|
smartdelay,
|
||||||
smartfile,
|
smartfile,
|
||||||
smartlog,
|
smartlog,
|
||||||
@ -31,7 +33,7 @@ export {
|
|||||||
tapbundle,
|
tapbundle,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @gitzone scope
|
// @git.zone scope
|
||||||
import * as tsbundle from '@git.zone/tsbundle';
|
import * as tsbundle from '@git.zone/tsbundle';
|
||||||
|
|
||||||
export { tsbundle };
|
export { tsbundle };
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
export { tap } from './tapbundle.classes.tap.js';
|
export { tap } from './tapbundle.classes.tap.js';
|
||||||
export { TapWrap } from './tapbundle.classes.tapwrap.js';
|
export { TapWrap } from './tapbundle.classes.tapwrap.js';
|
||||||
export { webhelpers } from './webhelpers.js';
|
export { webhelpers } from './webhelpers.js';
|
||||||
|
|
||||||
// Protocol utilities (for future protocol v2)
|
|
||||||
export * from './tapbundle.protocols.js';
|
|
||||||
export { TapTools } from './tapbundle.classes.taptools.js';
|
export { TapTools } from './tapbundle.classes.taptools.js';
|
||||||
|
|
||||||
import { expect } from '@push.rocks/smartexpect';
|
// Export enhanced expect with diff generation
|
||||||
|
export { expect, setProtocolEmitter } from './tapbundle.expect.wrapper.js';
|
||||||
export { expect };
|
|
||||||
|
117
ts_tapbundle/tapbundle.classes.settingsmanager.ts
Normal file
117
ts_tapbundle/tapbundle.classes.settingsmanager.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import type { ITapSettings, ISettingsManager } from './tapbundle.interfaces.js';
|
||||||
|
|
||||||
|
export class SettingsManager implements ISettingsManager {
|
||||||
|
private globalSettings: ITapSettings = {};
|
||||||
|
private fileSettings: ITapSettings = {};
|
||||||
|
private testSettings: Map<string, ITapSettings> = new Map();
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
private defaultSettings: ITapSettings = {
|
||||||
|
timeout: undefined, // No timeout by default
|
||||||
|
slowThreshold: 1000, // 1 second
|
||||||
|
bail: false,
|
||||||
|
retries: 0,
|
||||||
|
retryDelay: 0,
|
||||||
|
suppressConsole: false,
|
||||||
|
verboseErrors: true,
|
||||||
|
showTestDuration: true,
|
||||||
|
maxConcurrency: 5,
|
||||||
|
isolateTests: false,
|
||||||
|
enableSnapshots: true,
|
||||||
|
snapshotDirectory: '.snapshots',
|
||||||
|
updateSnapshots: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get merged settings for current context
|
||||||
|
*/
|
||||||
|
public getSettings(): ITapSettings {
|
||||||
|
return this.mergeSettings(
|
||||||
|
this.defaultSettings,
|
||||||
|
this.globalSettings,
|
||||||
|
this.fileSettings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set global settings (from 00init.ts or tap.settings())
|
||||||
|
*/
|
||||||
|
public setGlobalSettings(settings: ITapSettings): void {
|
||||||
|
this.globalSettings = { ...this.globalSettings, ...settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set file-level settings
|
||||||
|
*/
|
||||||
|
public setFileSettings(settings: ITapSettings): void {
|
||||||
|
this.fileSettings = { ...this.fileSettings, ...settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set test-specific settings
|
||||||
|
*/
|
||||||
|
public setTestSettings(testId: string, settings: ITapSettings): void {
|
||||||
|
const existingSettings = this.testSettings.get(testId) || {};
|
||||||
|
this.testSettings.set(testId, { ...existingSettings, ...settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings for specific test
|
||||||
|
*/
|
||||||
|
public getTestSettings(testId: string): ITapSettings {
|
||||||
|
const testSpecificSettings = this.testSettings.get(testId) || {};
|
||||||
|
return this.mergeSettings(
|
||||||
|
this.defaultSettings,
|
||||||
|
this.globalSettings,
|
||||||
|
this.fileSettings,
|
||||||
|
testSpecificSettings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge settings with proper inheritance
|
||||||
|
* Later settings override earlier ones
|
||||||
|
*/
|
||||||
|
private mergeSettings(...settingsArray: ITapSettings[]): ITapSettings {
|
||||||
|
const result: ITapSettings = {};
|
||||||
|
|
||||||
|
for (const settings of settingsArray) {
|
||||||
|
// Simple properties - later values override
|
||||||
|
if (settings.timeout !== undefined) result.timeout = settings.timeout;
|
||||||
|
if (settings.slowThreshold !== undefined) result.slowThreshold = settings.slowThreshold;
|
||||||
|
if (settings.bail !== undefined) result.bail = settings.bail;
|
||||||
|
if (settings.retries !== undefined) result.retries = settings.retries;
|
||||||
|
if (settings.retryDelay !== undefined) result.retryDelay = settings.retryDelay;
|
||||||
|
if (settings.suppressConsole !== undefined) result.suppressConsole = settings.suppressConsole;
|
||||||
|
if (settings.verboseErrors !== undefined) result.verboseErrors = settings.verboseErrors;
|
||||||
|
if (settings.showTestDuration !== undefined) result.showTestDuration = settings.showTestDuration;
|
||||||
|
if (settings.maxConcurrency !== undefined) result.maxConcurrency = settings.maxConcurrency;
|
||||||
|
if (settings.isolateTests !== undefined) result.isolateTests = settings.isolateTests;
|
||||||
|
if (settings.enableSnapshots !== undefined) result.enableSnapshots = settings.enableSnapshots;
|
||||||
|
if (settings.snapshotDirectory !== undefined) result.snapshotDirectory = settings.snapshotDirectory;
|
||||||
|
if (settings.updateSnapshots !== undefined) result.updateSnapshots = settings.updateSnapshots;
|
||||||
|
|
||||||
|
// Lifecycle hooks - later ones override
|
||||||
|
if (settings.beforeAll !== undefined) result.beforeAll = settings.beforeAll;
|
||||||
|
if (settings.afterAll !== undefined) result.afterAll = settings.afterAll;
|
||||||
|
if (settings.beforeEach !== undefined) result.beforeEach = settings.beforeEach;
|
||||||
|
if (settings.afterEach !== undefined) result.afterEach = settings.afterEach;
|
||||||
|
|
||||||
|
// Environment variables - merge
|
||||||
|
if (settings.env) {
|
||||||
|
result.env = { ...result.env, ...settings.env };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all settings (useful for testing)
|
||||||
|
*/
|
||||||
|
public clearSettings(): void {
|
||||||
|
this.globalSettings = {};
|
||||||
|
this.fileSettings = {};
|
||||||
|
this.testSettings.clear();
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,9 @@ import * as plugins from './tapbundle.plugins.js';
|
|||||||
|
|
||||||
import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js';
|
import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js';
|
||||||
import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
|
import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
|
||||||
|
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
import type { ITapSettings } from './tapbundle.interfaces.js';
|
||||||
|
import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
|
||||||
|
|
||||||
export interface ITestSuite {
|
export interface ITestSuite {
|
||||||
description: string;
|
description: string;
|
||||||
@ -102,6 +105,8 @@ class TestBuilder<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Tap<T> {
|
export class Tap<T> {
|
||||||
|
private protocolEmitter = new ProtocolEmitter();
|
||||||
|
private settingsManager = new SettingsManager();
|
||||||
private _skipCount = 0;
|
private _skipCount = 0;
|
||||||
private _filterTags: string[] = [];
|
private _filterTags: string[] = [];
|
||||||
|
|
||||||
@ -139,12 +144,27 @@ export class Tap<T> {
|
|||||||
*/
|
*/
|
||||||
public skip = {
|
public skip = {
|
||||||
test: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
test: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
||||||
console.log(`skipped test: ${descriptionArg}`);
|
const skippedTest = this.test(descriptionArg, functionArg, 'skip');
|
||||||
this._skipCount++;
|
return skippedTest;
|
||||||
},
|
},
|
||||||
testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => {
|
||||||
console.log(`skipped test: ${descriptionArg}`);
|
const skippedTest = new TapTest<T>({
|
||||||
this._skipCount++;
|
description: descriptionArg,
|
||||||
|
testFunction: functionArg,
|
||||||
|
parallel: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as skip mode
|
||||||
|
skippedTest.tapTools.markAsSkipped('Marked as skip');
|
||||||
|
|
||||||
|
// Add to appropriate test list
|
||||||
|
if (this._currentSuite) {
|
||||||
|
this._currentSuite.tests.push(skippedTest);
|
||||||
|
} else {
|
||||||
|
this._tapTests.push(skippedTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return skippedTest;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -153,7 +173,65 @@ export class Tap<T> {
|
|||||||
*/
|
*/
|
||||||
public only = {
|
public only = {
|
||||||
test: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
|
test: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
|
||||||
this.test(descriptionArg, testFunctionArg, 'only');
|
return this.test(descriptionArg, testFunctionArg, 'only');
|
||||||
|
},
|
||||||
|
testParallel: (descriptionArg: string, testFunctionArg: ITestFunction<T>) => {
|
||||||
|
const onlyTest = new TapTest<T>({
|
||||||
|
description: descriptionArg,
|
||||||
|
testFunction: testFunctionArg,
|
||||||
|
parallel: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to only tests list
|
||||||
|
this._tapTestsOnly.push(onlyTest);
|
||||||
|
|
||||||
|
return onlyTest;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mark a test as todo (not yet implemented)
|
||||||
|
*/
|
||||||
|
public todo = {
|
||||||
|
test: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
|
||||||
|
const defaultFunc = (async () => {}) as ITestFunction<T>;
|
||||||
|
const todoTest = new TapTest<T>({
|
||||||
|
description: descriptionArg,
|
||||||
|
testFunction: functionArg || defaultFunc,
|
||||||
|
parallel: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as todo
|
||||||
|
todoTest.tapTools.todo('Marked as todo');
|
||||||
|
|
||||||
|
// Add to appropriate test list
|
||||||
|
if (this._currentSuite) {
|
||||||
|
this._currentSuite.tests.push(todoTest);
|
||||||
|
} else {
|
||||||
|
this._tapTests.push(todoTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return todoTest;
|
||||||
|
},
|
||||||
|
testParallel: (descriptionArg: string, functionArg?: ITestFunction<T>) => {
|
||||||
|
const defaultFunc = (async () => {}) as ITestFunction<T>;
|
||||||
|
const todoTest = new TapTest<T>({
|
||||||
|
description: descriptionArg,
|
||||||
|
testFunction: functionArg || defaultFunc,
|
||||||
|
parallel: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as todo
|
||||||
|
todoTest.tapTools.todo('Marked as todo');
|
||||||
|
|
||||||
|
// Add to appropriate test list
|
||||||
|
if (this._currentSuite) {
|
||||||
|
this._currentSuite.tests.push(todoTest);
|
||||||
|
} else {
|
||||||
|
this._tapTests.push(todoTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return todoTest;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -163,6 +241,21 @@ export class Tap<T> {
|
|||||||
private _currentSuite: ITestSuite | null = null;
|
private _currentSuite: ITestSuite | null = null;
|
||||||
private _rootSuites: ITestSuite[] = [];
|
private _rootSuites: ITestSuite[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure global test settings
|
||||||
|
*/
|
||||||
|
public settings(settings: ITapSettings): this {
|
||||||
|
this.settingsManager.setGlobalSettings(settings);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current test settings
|
||||||
|
*/
|
||||||
|
public getSettings(): ITapSettings {
|
||||||
|
return this.settingsManager.getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normal test function, will run one by one
|
* Normal test function, will run one by one
|
||||||
* @param testDescription - A description of what the test does
|
* @param testDescription - A description of what the test does
|
||||||
@ -179,14 +272,26 @@ export class Tap<T> {
|
|||||||
parallel: false,
|
parallel: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// No options applied here - use the fluent builder syntax instead
|
// Apply default settings from settings manager
|
||||||
|
const settings = this.settingsManager.getSettings();
|
||||||
|
if (settings.timeout !== undefined) {
|
||||||
|
localTest.timeoutMs = settings.timeout;
|
||||||
|
}
|
||||||
|
if (settings.retries !== undefined) {
|
||||||
|
localTest.tapTools.retry(settings.retries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle skip mode
|
||||||
|
if (modeArg === 'skip') {
|
||||||
|
localTest.tapTools.markAsSkipped('Marked as skip');
|
||||||
|
}
|
||||||
|
|
||||||
// If we're in a suite, add test to the suite
|
// If we're in a suite, add test to the suite
|
||||||
if (this._currentSuite) {
|
if (this._currentSuite) {
|
||||||
this._currentSuite.tests.push(localTest);
|
this._currentSuite.tests.push(localTest);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise add to global test list
|
// Otherwise add to global test list
|
||||||
if (modeArg === 'normal') {
|
if (modeArg === 'normal' || modeArg === 'skip') {
|
||||||
this._tapTests.push(localTest);
|
this._tapTests.push(localTest);
|
||||||
} else if (modeArg === 'only') {
|
} else if (modeArg === 'only') {
|
||||||
this._tapTestsOnly.push(localTest);
|
this._tapTestsOnly.push(localTest);
|
||||||
@ -211,6 +316,15 @@ export class Tap<T> {
|
|||||||
parallel: true,
|
parallel: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply default settings from settings manager
|
||||||
|
const settings = this.settingsManager.getSettings();
|
||||||
|
if (settings.timeout !== undefined) {
|
||||||
|
localTest.timeoutMs = settings.timeout;
|
||||||
|
}
|
||||||
|
if (settings.retries !== undefined) {
|
||||||
|
localTest.tapTools.retry(settings.retries);
|
||||||
|
}
|
||||||
|
|
||||||
if (this._currentSuite) {
|
if (this._currentSuite) {
|
||||||
this._currentSuite.tests.push(localTest);
|
this._currentSuite.tests.push(localTest);
|
||||||
} else {
|
} else {
|
||||||
@ -336,8 +450,27 @@ export class Tap<T> {
|
|||||||
await preTask.run();
|
await preTask.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count actual tests that will be run
|
// Emit protocol header and TAP version
|
||||||
console.log(`1..${concerningTests.length}`);
|
console.log(this.protocolEmitter.emitProtocolHeader());
|
||||||
|
console.log(this.protocolEmitter.emitTapVersion(13));
|
||||||
|
|
||||||
|
// Emit test plan
|
||||||
|
const plan = {
|
||||||
|
start: 1,
|
||||||
|
end: concerningTests.length
|
||||||
|
};
|
||||||
|
console.log(this.protocolEmitter.emitPlan(plan));
|
||||||
|
|
||||||
|
// Run global beforeAll hook if configured
|
||||||
|
const settings = this.settingsManager.getSettings();
|
||||||
|
if (settings.beforeAll) {
|
||||||
|
try {
|
||||||
|
await settings.beforeAll();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in beforeAll hook:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run tests from suites with lifecycle hooks
|
// Run tests from suites with lifecycle hooks
|
||||||
let testKey = 0;
|
let testKey = 0;
|
||||||
@ -365,6 +498,33 @@ export class Tap<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const currentTest of nonSuiteTests) {
|
for (const currentTest of nonSuiteTests) {
|
||||||
|
// Wrap test function with global lifecycle hooks
|
||||||
|
const originalFunction = currentTest.testFunction;
|
||||||
|
const testName = currentTest.description;
|
||||||
|
currentTest.testFunction = async (tapTools) => {
|
||||||
|
// Run global beforeEach if configured
|
||||||
|
if (settings.beforeEach) {
|
||||||
|
await settings.beforeEach(testName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the actual test
|
||||||
|
let testPassed = true;
|
||||||
|
let result: any;
|
||||||
|
try {
|
||||||
|
result = await originalFunction(tapTools);
|
||||||
|
} catch (error) {
|
||||||
|
testPassed = false;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Run global afterEach if configured
|
||||||
|
if (settings.afterEach) {
|
||||||
|
await settings.afterEach(testName, testPassed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const testPromise = currentTest.run(testKey++);
|
const testPromise = currentTest.run(testKey++);
|
||||||
if (currentTest.parallel) {
|
if (currentTest.parallel) {
|
||||||
promiseArray.push(testPromise);
|
promiseArray.push(testPromise);
|
||||||
@ -394,6 +554,16 @@ export class Tap<T> {
|
|||||||
console.log(failReason);
|
console.log(failReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run global afterAll hook if configured
|
||||||
|
if (settings.afterAll) {
|
||||||
|
try {
|
||||||
|
await settings.afterAll();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in afterAll hook:', error);
|
||||||
|
// Don't throw here, we want to complete the test run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) {
|
if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) {
|
||||||
if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1);
|
if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1);
|
||||||
}
|
}
|
||||||
@ -402,6 +572,13 @@ export class Tap<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event
|
||||||
|
*/
|
||||||
|
private emitEvent(event: ITestEvent) {
|
||||||
|
console.log(this.protocolEmitter.emitEvent(event));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run tests in a suite with lifecycle hooks
|
* Run tests in a suite with lifecycle hooks
|
||||||
*/
|
*/
|
||||||
@ -412,6 +589,14 @@ export class Tap<T> {
|
|||||||
context: { testKey: number }
|
context: { testKey: number }
|
||||||
) {
|
) {
|
||||||
for (const suite of suites) {
|
for (const suite of suites) {
|
||||||
|
// Emit suite:started event
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'suite:started',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
suiteName: suite.description
|
||||||
|
}
|
||||||
|
});
|
||||||
// Run beforeEach from parent suites
|
// Run beforeEach from parent suites
|
||||||
const beforeEachFunctions: ITestFunction<any>[] = [];
|
const beforeEachFunctions: ITestFunction<any>[] = [];
|
||||||
let currentSuite: ITestSuite | null = suite;
|
let currentSuite: ITestSuite | null = suite;
|
||||||
@ -426,27 +611,46 @@ export class Tap<T> {
|
|||||||
for (const test of suite.tests) {
|
for (const test of suite.tests) {
|
||||||
// Create wrapper test function that includes lifecycle hooks
|
// Create wrapper test function that includes lifecycle hooks
|
||||||
const originalFunction = test.testFunction;
|
const originalFunction = test.testFunction;
|
||||||
|
const testName = test.description;
|
||||||
test.testFunction = async (tapTools) => {
|
test.testFunction = async (tapTools) => {
|
||||||
// Run all beforeEach hooks
|
// Run global beforeEach if configured
|
||||||
|
const settings = this.settingsManager.getSettings();
|
||||||
|
if (settings.beforeEach) {
|
||||||
|
await settings.beforeEach(testName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all suite beforeEach hooks
|
||||||
for (const beforeEach of beforeEachFunctions) {
|
for (const beforeEach of beforeEachFunctions) {
|
||||||
await beforeEach(tapTools);
|
await beforeEach(tapTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the actual test
|
// Run the actual test
|
||||||
const result = await originalFunction(tapTools);
|
let testPassed = true;
|
||||||
|
let result: any;
|
||||||
// Run afterEach hooks in reverse order
|
try {
|
||||||
const afterEachFunctions: ITestFunction<any>[] = [];
|
result = await originalFunction(tapTools);
|
||||||
currentSuite = suite;
|
} catch (error) {
|
||||||
while (currentSuite) {
|
testPassed = false;
|
||||||
if (currentSuite.afterEach) {
|
throw error;
|
||||||
afterEachFunctions.push(currentSuite.afterEach);
|
} finally {
|
||||||
|
// Run afterEach hooks in reverse order
|
||||||
|
const afterEachFunctions: ITestFunction<any>[] = [];
|
||||||
|
currentSuite = suite;
|
||||||
|
while (currentSuite) {
|
||||||
|
if (currentSuite.afterEach) {
|
||||||
|
afterEachFunctions.push(currentSuite.afterEach);
|
||||||
|
}
|
||||||
|
currentSuite = currentSuite.parent || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const afterEach of afterEachFunctions) {
|
||||||
|
await afterEach(tapTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run global afterEach if configured
|
||||||
|
if (settings.afterEach) {
|
||||||
|
await settings.afterEach(testName, testPassed);
|
||||||
}
|
}
|
||||||
currentSuite = currentSuite.parent || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const afterEach of afterEachFunctions) {
|
|
||||||
await afterEach(tapTools);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -462,6 +666,15 @@ export class Tap<T> {
|
|||||||
|
|
||||||
// Recursively run child suites
|
// Recursively run child suites
|
||||||
await this._runSuite(suite, suite.children, promiseArray, context);
|
await this._runSuite(suite, suite.children, promiseArray, context);
|
||||||
|
|
||||||
|
// Emit suite:completed event
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'suite:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
suiteName: suite.description
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import * as plugins from './tapbundle.plugins.js';
|
import * as plugins from './tapbundle.plugins.js';
|
||||||
import { tapCreator } from './tapbundle.tapcreator.js';
|
import { tapCreator } from './tapbundle.tapcreator.js';
|
||||||
import { TapTools, SkipError } from './tapbundle.classes.taptools.js';
|
import { TapTools, SkipError } from './tapbundle.classes.taptools.js';
|
||||||
|
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
import { setProtocolEmitter } from './tapbundle.expect.wrapper.js';
|
||||||
|
|
||||||
// imported interfaces
|
// imported interfaces
|
||||||
import { Deferred } from '@push.rocks/smartpromise';
|
import { Deferred } from '@push.rocks/smartpromise';
|
||||||
@ -32,6 +34,7 @@ export class TapTest<T = unknown> {
|
|||||||
public testPromise: Promise<TapTest<T>> = this.testDeferred.promise;
|
public testPromise: Promise<TapTest<T>> = this.testDeferred.promise;
|
||||||
private testResultDeferred: Deferred<T> = plugins.smartpromise.defer();
|
private testResultDeferred: Deferred<T> = plugins.smartpromise.defer();
|
||||||
public testResultPromise: Promise<T> = this.testResultDeferred.promise;
|
public testResultPromise: Promise<T> = this.testResultDeferred.promise;
|
||||||
|
private protocolEmitter = new ProtocolEmitter();
|
||||||
/**
|
/**
|
||||||
* constructor
|
* constructor
|
||||||
*/
|
*/
|
||||||
@ -48,6 +51,13 @@ export class TapTest<T = unknown> {
|
|||||||
this.testFunction = optionsArg.testFunction;
|
this.testFunction = optionsArg.testFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event
|
||||||
|
*/
|
||||||
|
private emitEvent(event: ITestEvent) {
|
||||||
|
console.log(this.protocolEmitter.emitEvent(event));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* run the test
|
* run the test
|
||||||
*/
|
*/
|
||||||
@ -55,11 +65,74 @@ export class TapTest<T = unknown> {
|
|||||||
this.testKey = testKeyArg;
|
this.testKey = testKeyArg;
|
||||||
const testNumber = testKeyArg + 1;
|
const testNumber = testKeyArg + 1;
|
||||||
|
|
||||||
|
// Emit test:queued event
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:queued',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle todo tests
|
// Handle todo tests
|
||||||
if (this.isTodo) {
|
if (this.isTodo) {
|
||||||
const todoText = this.todoReason ? `# TODO ${this.todoReason}` : '# TODO';
|
const testResult = {
|
||||||
console.log(`ok ${testNumber} - ${this.description} ${todoText}`);
|
ok: true,
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
directive: {
|
||||||
|
type: 'todo' as const,
|
||||||
|
reason: this.todoReason
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const lines = this.protocolEmitter.emitTest(testResult);
|
||||||
|
lines.forEach((line: string) => console.log(line));
|
||||||
this.status = 'success';
|
this.status = 'success';
|
||||||
|
|
||||||
|
// Emit test:completed event for todo test
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
duration: 0,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.testDeferred.resolve(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pre-marked skip tests
|
||||||
|
if (this.tapTools.isSkipped) {
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
directive: {
|
||||||
|
type: 'skip' as const,
|
||||||
|
reason: this.tapTools.skipReason || 'Marked as skip'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const lines = this.protocolEmitter.emitTest(testResult);
|
||||||
|
lines.forEach((line: string) => console.log(line));
|
||||||
|
this.status = 'skipped';
|
||||||
|
|
||||||
|
// Emit test:completed event for skipped test
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
duration: 0,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.testDeferred.resolve(this);
|
this.testDeferred.resolve(this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -71,6 +144,20 @@ export class TapTest<T = unknown> {
|
|||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
this.hrtMeasurement.start();
|
this.hrtMeasurement.start();
|
||||||
|
|
||||||
|
// Emit test:started event
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:started',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
retry: attempt > 0 ? attempt : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set protocol emitter for enhanced expect
|
||||||
|
setProtocolEmitter(this.protocolEmitter);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set up timeout if specified
|
// Set up timeout if specified
|
||||||
let timeoutHandle: any;
|
let timeoutHandle: any;
|
||||||
@ -97,10 +184,32 @@ export class TapTest<T = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.hrtMeasurement.stop();
|
this.hrtMeasurement.stop();
|
||||||
console.log(
|
const testResult = {
|
||||||
`ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
|
ok: true,
|
||||||
);
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
metadata: {
|
||||||
|
time: this.hrtMeasurement.milliSeconds,
|
||||||
|
tags: this.tags.length > 0 ? this.tags : undefined,
|
||||||
|
file: this.fileName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const lines = this.protocolEmitter.emitTest(testResult);
|
||||||
|
lines.forEach((line: string) => console.log(line));
|
||||||
this.status = 'success';
|
this.status = 'success';
|
||||||
|
|
||||||
|
// Emit test:completed event
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
duration: this.hrtMeasurement.milliSeconds,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.testDeferred.resolve(this);
|
this.testDeferred.resolve(this);
|
||||||
this.testResultDeferred.resolve(testReturnValue);
|
this.testResultDeferred.resolve(testReturnValue);
|
||||||
return; // Success, exit retry loop
|
return; // Success, exit retry loop
|
||||||
@ -110,8 +219,31 @@ export class TapTest<T = unknown> {
|
|||||||
|
|
||||||
// Handle skip
|
// Handle skip
|
||||||
if (err instanceof SkipError || err.name === 'SkipError') {
|
if (err instanceof SkipError || err.name === 'SkipError') {
|
||||||
console.log(`ok ${testNumber} - ${this.description} # SKIP ${err.message.replace('Skipped: ', '')}`);
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
directive: {
|
||||||
|
type: 'skip' as const,
|
||||||
|
reason: err.message.replace('Skipped: ', '')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const lines = this.protocolEmitter.emitTest(testResult);
|
||||||
|
lines.forEach((line: string) => console.log(line));
|
||||||
this.status = 'skipped';
|
this.status = 'skipped';
|
||||||
|
|
||||||
|
// Emit test:completed event for skipped test
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
duration: this.hrtMeasurement.milliSeconds,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.testDeferred.resolve(this);
|
this.testDeferred.resolve(this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -120,17 +252,48 @@ export class TapTest<T = unknown> {
|
|||||||
|
|
||||||
// If we have retries left, try again
|
// If we have retries left, try again
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
console.log(
|
console.log(this.protocolEmitter.emitComment(`Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`));
|
||||||
`# Retry ${attempt + 1}/${maxRetries} for test: ${this.description}`,
|
|
||||||
);
|
|
||||||
this.tapTools._incrementRetryCount();
|
this.tapTools._incrementRetryCount();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final failure
|
// Final failure
|
||||||
console.log(
|
const testResult = {
|
||||||
`not ok ${testNumber} - ${this.description} # time=${this.hrtMeasurement.milliSeconds}ms`,
|
ok: false,
|
||||||
);
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
metadata: {
|
||||||
|
time: this.hrtMeasurement.milliSeconds,
|
||||||
|
retry: this.tapTools.retryCount,
|
||||||
|
maxRetries: maxRetries > 0 ? maxRetries : undefined,
|
||||||
|
error: {
|
||||||
|
message: lastError.message || String(lastError),
|
||||||
|
stack: lastError.stack,
|
||||||
|
code: lastError.code
|
||||||
|
},
|
||||||
|
tags: this.tags.length > 0 ? this.tags : undefined,
|
||||||
|
file: this.fileName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const lines = this.protocolEmitter.emitTest(testResult);
|
||||||
|
lines.forEach((line: string) => console.log(line));
|
||||||
|
|
||||||
|
// Emit test:completed event for failed test
|
||||||
|
this.emitEvent({
|
||||||
|
eventType: 'test:completed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber,
|
||||||
|
description: this.description,
|
||||||
|
duration: this.hrtMeasurement.milliSeconds,
|
||||||
|
error: {
|
||||||
|
message: lastError.message || String(lastError),
|
||||||
|
stack: lastError.stack,
|
||||||
|
type: 'runtime' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.testDeferred.resolve(this);
|
this.testDeferred.resolve(this);
|
||||||
this.testResultDeferred.resolve(err);
|
this.testResultDeferred.resolve(err);
|
||||||
|
|
||||||
|
@ -22,6 +22,10 @@ export class TapTools {
|
|||||||
public testData: any = {};
|
public testData: any = {};
|
||||||
private static _sharedContext = new Map<string, any>();
|
private static _sharedContext = new Map<string, any>();
|
||||||
private _snapshotPath: string = '';
|
private _snapshotPath: string = '';
|
||||||
|
|
||||||
|
// Flags for skip/todo
|
||||||
|
private _isSkipped = false;
|
||||||
|
private _skipReason?: string;
|
||||||
|
|
||||||
constructor(TapTestArg: TapTest<any>) {
|
constructor(TapTestArg: TapTest<any>) {
|
||||||
this._tapTest = TapTestArg;
|
this._tapTest = TapTestArg;
|
||||||
@ -45,9 +49,33 @@ export class TapTools {
|
|||||||
* skip the rest of the test
|
* skip the rest of the test
|
||||||
*/
|
*/
|
||||||
public skip(reason?: string): never {
|
public skip(reason?: string): never {
|
||||||
|
this._isSkipped = true;
|
||||||
|
this._skipReason = reason;
|
||||||
const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped';
|
const skipMessage = reason ? `Skipped: ${reason}` : 'Skipped';
|
||||||
throw new SkipError(skipMessage);
|
throw new SkipError(skipMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark test as skipped without throwing (for pre-marking)
|
||||||
|
*/
|
||||||
|
public markAsSkipped(reason?: string): void {
|
||||||
|
this._isSkipped = true;
|
||||||
|
this._skipReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if test is marked as skipped
|
||||||
|
*/
|
||||||
|
public get isSkipped(): boolean {
|
||||||
|
return this._isSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skip reason
|
||||||
|
*/
|
||||||
|
public get skipReason(): string | undefined {
|
||||||
|
return this._skipReason;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* conditionally skip the rest of the test
|
* conditionally skip the rest of the test
|
||||||
|
81
ts_tapbundle/tapbundle.expect.wrapper.ts
Normal file
81
ts_tapbundle/tapbundle.expect.wrapper.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { expect as smartExpect } from '@push.rocks/smartexpect';
|
||||||
|
import { generateDiff } from './tapbundle.utilities.diff.js';
|
||||||
|
import { ProtocolEmitter } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
import type { IEnhancedError } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
|
||||||
|
// Store the protocol emitter for event emission
|
||||||
|
let protocolEmitter: ProtocolEmitter | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the protocol emitter for enhanced error reporting
|
||||||
|
*/
|
||||||
|
export function setProtocolEmitter(emitter: ProtocolEmitter) {
|
||||||
|
protocolEmitter = emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced expect wrapper that captures assertion failures and generates diffs
|
||||||
|
*/
|
||||||
|
export function createEnhancedExpect() {
|
||||||
|
return new Proxy(smartExpect, {
|
||||||
|
apply(target, thisArg, argumentsList: any[]) {
|
||||||
|
const expectation = target.apply(thisArg, argumentsList);
|
||||||
|
|
||||||
|
// Wrap common assertion methods
|
||||||
|
const wrappedExpectation = new Proxy(expectation, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
const originalValue = Reflect.get(target, prop, receiver);
|
||||||
|
|
||||||
|
// Wrap assertion methods that compare values
|
||||||
|
if (typeof prop === 'string' && typeof originalValue === 'function' && ['toEqual', 'toBe', 'toMatch', 'toContain'].includes(prop)) {
|
||||||
|
return function(expected: any) {
|
||||||
|
try {
|
||||||
|
return originalValue.apply(target, arguments);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Enhance the error with diff information
|
||||||
|
const actual = argumentsList[0];
|
||||||
|
const enhancedError: IEnhancedError = {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
type: 'assertion'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate diff if applicable
|
||||||
|
if (prop === 'toEqual' || prop === 'toBe') {
|
||||||
|
const diff = generateDiff(expected, actual);
|
||||||
|
if (diff) {
|
||||||
|
enhancedError.diff = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit assertion:failed event if protocol emitter is available
|
||||||
|
if (protocolEmitter) {
|
||||||
|
const event = {
|
||||||
|
eventType: 'assertion:failed' as const,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
error: enhancedError
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log(protocolEmitter.emitEvent(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw the enhanced error
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return wrappedExpectation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the enhanced expect function
|
||||||
|
export const expect = createEnhancedExpect();
|
46
ts_tapbundle/tapbundle.interfaces.ts
Normal file
46
ts_tapbundle/tapbundle.interfaces.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface ITapSettings {
|
||||||
|
// Timing
|
||||||
|
timeout?: number; // Default timeout for all tests (ms)
|
||||||
|
slowThreshold?: number; // Mark tests as slow if they exceed this (ms)
|
||||||
|
|
||||||
|
// Execution Control
|
||||||
|
bail?: boolean; // Stop on first test failure
|
||||||
|
retries?: number; // Number of retries for failed tests
|
||||||
|
retryDelay?: number; // Delay between retries (ms)
|
||||||
|
|
||||||
|
// Output Control
|
||||||
|
suppressConsole?: boolean; // Suppress console output in passing tests
|
||||||
|
verboseErrors?: boolean; // Show full stack traces
|
||||||
|
showTestDuration?: boolean; // Show duration for each test
|
||||||
|
|
||||||
|
// Parallel Execution
|
||||||
|
maxConcurrency?: number; // Max parallel tests (for .para files)
|
||||||
|
isolateTests?: boolean; // Run each test in fresh context
|
||||||
|
|
||||||
|
// Lifecycle Hooks
|
||||||
|
beforeAll?: () => Promise<void> | void;
|
||||||
|
afterAll?: () => Promise<void> | void;
|
||||||
|
beforeEach?: (testName: string) => Promise<void> | void;
|
||||||
|
afterEach?: (testName: string, passed: boolean) => Promise<void> | void;
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
env?: Record<string, string>; // Additional environment variables
|
||||||
|
|
||||||
|
// Features
|
||||||
|
enableSnapshots?: boolean; // Enable snapshot testing
|
||||||
|
snapshotDirectory?: string; // Custom snapshot directory
|
||||||
|
updateSnapshots?: boolean; // Update snapshots instead of comparing
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISettingsManager {
|
||||||
|
// Get merged settings for current context
|
||||||
|
getSettings(): ITapSettings;
|
||||||
|
|
||||||
|
// Apply settings at different levels
|
||||||
|
setGlobalSettings(settings: ITapSettings): void;
|
||||||
|
setFileSettings(settings: ITapSettings): void;
|
||||||
|
setTestSettings(testId: string, settings: ITapSettings): void;
|
||||||
|
|
||||||
|
// Get settings for specific test
|
||||||
|
getTestSettings(testId: string): ITapSettings;
|
||||||
|
}
|
@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Internal protocol constants and utilities for improved TAP communication
|
|
||||||
* between tapbundle and tstest
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const PROTOCOL = {
|
|
||||||
VERSION: '2.0',
|
|
||||||
MARKERS: {
|
|
||||||
START: '⟦TSTEST:',
|
|
||||||
END: '⟧',
|
|
||||||
BLOCK_END: '⟦/TSTEST:',
|
|
||||||
},
|
|
||||||
TYPES: {
|
|
||||||
META: 'META',
|
|
||||||
ERROR: 'ERROR',
|
|
||||||
SKIP: 'SKIP',
|
|
||||||
TODO: 'TODO',
|
|
||||||
SNAPSHOT: 'SNAPSHOT',
|
|
||||||
PROTOCOL: 'PROTOCOL',
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface TestMetadata {
|
|
||||||
// Timing
|
|
||||||
time?: number; // milliseconds
|
|
||||||
startTime?: number; // Unix timestamp
|
|
||||||
endTime?: number; // Unix timestamp
|
|
||||||
|
|
||||||
// Status
|
|
||||||
skip?: string; // skip reason
|
|
||||||
todo?: string; // todo reason
|
|
||||||
retry?: number; // retry attempt
|
|
||||||
maxRetries?: number; // max retries allowed
|
|
||||||
|
|
||||||
// Error details
|
|
||||||
error?: {
|
|
||||||
message: string;
|
|
||||||
stack?: string;
|
|
||||||
diff?: string;
|
|
||||||
actual?: any;
|
|
||||||
expected?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test context
|
|
||||||
file?: string; // source file
|
|
||||||
line?: number; // line number
|
|
||||||
column?: number; // column number
|
|
||||||
|
|
||||||
// Custom data
|
|
||||||
tags?: string[]; // test tags
|
|
||||||
custom?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProtocolEncoder {
|
|
||||||
/**
|
|
||||||
* Encode metadata for inline inclusion
|
|
||||||
*/
|
|
||||||
static encodeInline(type: string, data: any): string {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
return `${PROTOCOL.MARKERS.START}${type}:${data}${PROTOCOL.MARKERS.END}`;
|
|
||||||
}
|
|
||||||
return `${PROTOCOL.MARKERS.START}${type}:${JSON.stringify(data)}${PROTOCOL.MARKERS.END}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode block data for multi-line content
|
|
||||||
*/
|
|
||||||
static encodeBlock(type: string, data: any): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(`${PROTOCOL.MARKERS.START}${type}${PROTOCOL.MARKERS.END}`);
|
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
lines.push(data);
|
|
||||||
} else {
|
|
||||||
lines.push(JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`);
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a TAP line with metadata
|
|
||||||
*/
|
|
||||||
static createTestLine(
|
|
||||||
status: 'ok' | 'not ok',
|
|
||||||
number: number,
|
|
||||||
description: string,
|
|
||||||
metadata?: TestMetadata
|
|
||||||
): string {
|
|
||||||
let line = `${status} ${number} - ${description}`;
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
// For skip/todo, use inline format for compatibility
|
|
||||||
if (metadata.skip) {
|
|
||||||
line += ` ${this.encodeInline(PROTOCOL.TYPES.SKIP, metadata.skip)}`;
|
|
||||||
} else if (metadata.todo) {
|
|
||||||
line += ` ${this.encodeInline(PROTOCOL.TYPES.TODO, metadata.todo)}`;
|
|
||||||
} else {
|
|
||||||
// For other metadata, append inline
|
|
||||||
const metaCopy = { ...metadata };
|
|
||||||
delete metaCopy.error; // Error details go in separate block
|
|
||||||
|
|
||||||
if (Object.keys(metaCopy).length > 0) {
|
|
||||||
line += ` ${this.encodeInline(PROTOCOL.TYPES.META, metaCopy)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProtocolDecoder {
|
|
||||||
/**
|
|
||||||
* Extract all protocol markers from a line
|
|
||||||
*/
|
|
||||||
static extractMarkers(line: string): Array<{type: string, data: any, start: number, end: number}> {
|
|
||||||
const markers: Array<{type: string, data: any, start: number, end: number}> = [];
|
|
||||||
let searchFrom = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const start = line.indexOf(PROTOCOL.MARKERS.START, searchFrom);
|
|
||||||
if (start === -1) break;
|
|
||||||
|
|
||||||
const end = line.indexOf(PROTOCOL.MARKERS.END, start);
|
|
||||||
if (end === -1) break;
|
|
||||||
|
|
||||||
const content = line.substring(start + PROTOCOL.MARKERS.START.length, end);
|
|
||||||
const colonIndex = content.indexOf(':');
|
|
||||||
|
|
||||||
if (colonIndex !== -1) {
|
|
||||||
const type = content.substring(0, colonIndex);
|
|
||||||
const dataStr = content.substring(colonIndex + 1);
|
|
||||||
|
|
||||||
let data: any;
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON first
|
|
||||||
data = JSON.parse(dataStr);
|
|
||||||
} catch {
|
|
||||||
// If not JSON, treat as string
|
|
||||||
data = dataStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
markers.push({
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
start,
|
|
||||||
end: end + PROTOCOL.MARKERS.END.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
searchFrom = end + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return markers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove protocol markers from a line
|
|
||||||
*/
|
|
||||||
static cleanLine(line: string): string {
|
|
||||||
const markers = this.extractMarkers(line);
|
|
||||||
|
|
||||||
// Remove markers from end to start to preserve indices
|
|
||||||
let cleanedLine = line;
|
|
||||||
for (let i = markers.length - 1; i >= 0; i--) {
|
|
||||||
const marker = markers[i];
|
|
||||||
cleanedLine = cleanedLine.substring(0, marker.start) +
|
|
||||||
cleanedLine.substring(marker.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanedLine.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a test line and extract metadata
|
|
||||||
*/
|
|
||||||
static parseTestLine(line: string): {
|
|
||||||
cleaned: string;
|
|
||||||
metadata: TestMetadata;
|
|
||||||
} {
|
|
||||||
const markers = this.extractMarkers(line);
|
|
||||||
const metadata: TestMetadata = {};
|
|
||||||
|
|
||||||
for (const marker of markers) {
|
|
||||||
switch (marker.type) {
|
|
||||||
case PROTOCOL.TYPES.META:
|
|
||||||
Object.assign(metadata, marker.data);
|
|
||||||
break;
|
|
||||||
case PROTOCOL.TYPES.SKIP:
|
|
||||||
metadata.skip = marker.data;
|
|
||||||
break;
|
|
||||||
case PROTOCOL.TYPES.TODO:
|
|
||||||
metadata.todo = marker.data;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cleaned: this.cleanLine(line),
|
|
||||||
metadata
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a line starts a protocol block
|
|
||||||
*/
|
|
||||||
static isBlockStart(line: string): {isBlock: boolean, type?: string} {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.startsWith(PROTOCOL.MARKERS.START) && trimmed.endsWith(PROTOCOL.MARKERS.END)) {
|
|
||||||
const content = trimmed.slice(PROTOCOL.MARKERS.START.length, -PROTOCOL.MARKERS.END.length);
|
|
||||||
if (!content.includes(':')) {
|
|
||||||
return { isBlock: true, type: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { isBlock: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a line ends a protocol block
|
|
||||||
*/
|
|
||||||
static isBlockEnd(line: string, type: string): boolean {
|
|
||||||
return line.trim() === `${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`;
|
|
||||||
}
|
|
||||||
}
|
|
188
ts_tapbundle/tapbundle.utilities.diff.ts
Normal file
188
ts_tapbundle/tapbundle.utilities.diff.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import type { IDiffResult, IDiffChange } from '../dist_ts_tapbundle_protocol/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a diff between two values
|
||||||
|
*/
|
||||||
|
export function generateDiff(expected: any, actual: any, context: number = 3): IDiffResult | null {
|
||||||
|
// Handle same values
|
||||||
|
if (expected === actual) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine diff type based on values
|
||||||
|
if (typeof expected === 'string' && typeof actual === 'string') {
|
||||||
|
return generateStringDiff(expected, actual, context);
|
||||||
|
} else if (Array.isArray(expected) && Array.isArray(actual)) {
|
||||||
|
return generateArrayDiff(expected, actual);
|
||||||
|
} else if (expected && actual && typeof expected === 'object' && typeof actual === 'object') {
|
||||||
|
return generateObjectDiff(expected, actual);
|
||||||
|
} else {
|
||||||
|
return generatePrimitiveDiff(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate diff for primitive values
|
||||||
|
*/
|
||||||
|
function generatePrimitiveDiff(expected: any, actual: any): IDiffResult {
|
||||||
|
return {
|
||||||
|
type: 'primitive',
|
||||||
|
changes: [{
|
||||||
|
type: 'modify',
|
||||||
|
oldValue: expected,
|
||||||
|
newValue: actual
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate diff for strings (line-by-line)
|
||||||
|
*/
|
||||||
|
function generateStringDiff(expected: string, actual: string, context: number): IDiffResult {
|
||||||
|
const expectedLines = expected.split('\n');
|
||||||
|
const actualLines = actual.split('\n');
|
||||||
|
const changes: IDiffChange[] = [];
|
||||||
|
|
||||||
|
// Simple line-by-line diff
|
||||||
|
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const expectedLine = expectedLines[i];
|
||||||
|
const actualLine = actualLines[i];
|
||||||
|
|
||||||
|
if (expectedLine === undefined) {
|
||||||
|
changes.push({
|
||||||
|
type: 'add',
|
||||||
|
line: i,
|
||||||
|
content: actualLine
|
||||||
|
});
|
||||||
|
} else if (actualLine === undefined) {
|
||||||
|
changes.push({
|
||||||
|
type: 'remove',
|
||||||
|
line: i,
|
||||||
|
content: expectedLine
|
||||||
|
});
|
||||||
|
} else if (expectedLine !== actualLine) {
|
||||||
|
changes.push({
|
||||||
|
type: 'remove',
|
||||||
|
line: i,
|
||||||
|
content: expectedLine
|
||||||
|
});
|
||||||
|
changes.push({
|
||||||
|
type: 'add',
|
||||||
|
line: i,
|
||||||
|
content: actualLine
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'string',
|
||||||
|
changes,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate diff for arrays
|
||||||
|
*/
|
||||||
|
function generateArrayDiff(expected: any[], actual: any[]): IDiffResult {
|
||||||
|
const changes: IDiffChange[] = [];
|
||||||
|
const maxLength = Math.max(expected.length, actual.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
const expectedItem = expected[i];
|
||||||
|
const actualItem = actual[i];
|
||||||
|
|
||||||
|
if (i >= expected.length) {
|
||||||
|
changes.push({
|
||||||
|
type: 'add',
|
||||||
|
path: [String(i)],
|
||||||
|
newValue: actualItem
|
||||||
|
});
|
||||||
|
} else if (i >= actual.length) {
|
||||||
|
changes.push({
|
||||||
|
type: 'remove',
|
||||||
|
path: [String(i)],
|
||||||
|
oldValue: expectedItem
|
||||||
|
});
|
||||||
|
} else if (!deepEqual(expectedItem, actualItem)) {
|
||||||
|
changes.push({
|
||||||
|
type: 'modify',
|
||||||
|
path: [String(i)],
|
||||||
|
oldValue: expectedItem,
|
||||||
|
newValue: actualItem
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'array',
|
||||||
|
changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate diff for objects
|
||||||
|
*/
|
||||||
|
function generateObjectDiff(expected: any, actual: any): IDiffResult {
|
||||||
|
const changes: IDiffChange[] = [];
|
||||||
|
const allKeys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const expectedValue = expected[key];
|
||||||
|
const actualValue = actual[key];
|
||||||
|
|
||||||
|
if (!(key in expected)) {
|
||||||
|
changes.push({
|
||||||
|
type: 'add',
|
||||||
|
path: [key],
|
||||||
|
newValue: actualValue
|
||||||
|
});
|
||||||
|
} else if (!(key in actual)) {
|
||||||
|
changes.push({
|
||||||
|
type: 'remove',
|
||||||
|
path: [key],
|
||||||
|
oldValue: expectedValue
|
||||||
|
});
|
||||||
|
} else if (!deepEqual(expectedValue, actualValue)) {
|
||||||
|
changes.push({
|
||||||
|
type: 'modify',
|
||||||
|
path: [key],
|
||||||
|
oldValue: expectedValue,
|
||||||
|
newValue: actualValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep equality check
|
||||||
|
*/
|
||||||
|
function deepEqual(a: any, b: any): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
|
||||||
|
if (a === null || b === null) return false;
|
||||||
|
if (typeof a !== typeof b) return false;
|
||||||
|
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((item, index) => deepEqual(item, b[index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysA = Object.keys(a);
|
||||||
|
const keysB = Object.keys(b);
|
||||||
|
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
|
||||||
|
return keysA.every(key => deepEqual(a[key], b[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
@ -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(':') && !simpleMatch[1].includes('META:') && !simpleMatch[1].includes('SKIP:') && !simpleMatch[1].includes('TODO:') && !simpleMatch[1].includes('EVENT:')) {
|
||||||
|
// This is a simple key:value format (not a prefixed format)
|
||||||
|
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