Compare commits

..

37 Commits

Author SHA1 Message Date
ecf11efb4c 2.4.1 2025-10-10 16:45:06 +00:00
1de674e91d fix(runtime/deno): Enable Deno runtime tests by adding required permissions and local settings 2025-10-10 16:45:06 +00:00
9fa2c23ab2 2.4.0 2025-10-10 16:35:22 +00:00
36715c9139 feat(runtime): Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests 2025-10-10 16:35:22 +00:00
ee0aca9ff7 2.3.8 2025-09-12 18:51:28 +00:00
aaebe75326 fix(tstest): Improve free port selection for Chrome runner and bump smartnetwork dependency 2025-09-12 18:51:28 +00:00
265ed702ee 2.3.7 2025-09-12 14:09:28 +00:00
efbaded1f3 fix(tests): Remove flaky dynamic-ports browser test and add local dev tool settings 2025-09-12 14:09:28 +00:00
799a60188f feat(tstest): Implement dynamic port allocation for HTTP and WebSocket connections, add tests for port validation 2025-09-12 14:06:03 +00:00
3c38a53d9d 2.3.6 2025-09-03 12:37:57 +00:00
cca01b51ec fix(tstest): Update deps, fix chrome server route for static bundles, add local tool settings and CI ignore 2025-09-03 12:37:57 +00:00
84843ad359 2.3.5 2025-08-18 02:40:44 +00:00
7a8ae95be2 fix(core): Use SmartRequest with Buffer for binary downloads, tighten static route handling, bump dependencies and add workspace/config files 2025-08-18 02:40:44 +00:00
133e0eda8b 2.3.4 2025-08-16 18:07:57 +00:00
14e32b06de fix(ci): Add local Claude settings to allow required WebFetch and Bash permissions for local tooling and tests 2025-08-16 18:07:57 +00:00
48aebb1eac 2.3.3 2025-08-16 18:01:44 +00:00
733b2249d0 fix(dependencies): Bump dependency versions and add local Claude settings 2025-08-16 18:01:44 +00:00
008844a9e2 fix(tapbundle): Fix TypeScript IDE warning about tapTools parameter possibly being undefined 2025-07-24 22:24:52 +00:00
e4fc6623ea 2.3.1 2025-05-26 16:07:17 +00:00
70435cce45 fix(tapParser/logger): Fix test duration reporting and summary formatting in TAP parser and logger 2025-05-26 16:07:17 +00:00
c26145205f 2.3.0 2025-05-26 14:20:56 +00:00
82fc22653b feat(cli): Add --version option and warn against global tstest usage in the tstest project 2025-05-26 14:20:55 +00:00
3d85f54be0 2.2.6 2025-05-26 14:04:41 +00:00
9464c17c15 fix(tstest): Improve timeout warning timer management and summary output formatting in the test runner. 2025-05-26 14:04:40 +00:00
91b99ce304 2.2.5 2025-05-26 08:22:26 +00:00
899045e6aa fix(protocol): Fix inline timing metadata parsing and enhance test coverage for performance metrics and timing edge cases 2025-05-26 08:22:26 +00:00
845f146e91 2.2.4 2025-05-26 05:01:06 +00:00
d1f8652fc7 fix(logging): Improve performance metrics reporting and add local permissions configuration 2025-05-26 05:01:06 +00:00
f717078558 2.2.3 2025-05-26 04:55:42 +00:00
d2c0e533b5 fix(readme/ts/tstest.plugins): Update npm package scope and documentation to use @git.zone instead of @gitzone, and add local settings configuration. 2025-05-26 04:55:42 +00:00
d3c7fce595 2.2.2 2025-05-26 04:46:25 +00:00
570e2d6b3b fix(config): Cleanup project configuration by adding local CLAUDE settings and removing redundant license files 2025-05-26 04:46:25 +00:00
b7f4b7b3b8 2.2.1 2025-05-26 04:40:10 +00:00
424046b0de fix(repo configuration): Update repository metadata to use git.zone naming and add local permission settings 2025-05-26 04:40:10 +00:00
0f762f2063 2.2.0 2025-05-26 04:37:38 +00:00
82757c4abc feat(watch mode): Add watch mode support with CLI options and enhanced documentation 2025-05-26 04:37:38 +00:00
7aaeed0dc6 fix: Implement tap.todo(), fix tap.skip.test() to create test objects, and ensure tap.only.test() works correctly
- tap.todo.test() now creates proper test objects marked as todo
- tap.skip.test() creates test objects instead of just logging
- tap.only.test() properly filters to run only marked tests
- Added markAsSkipped() method for pre-test skip marking
- All test types now contribute to accurate test counts
- Updated documentation to reflect these fixes

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-05-26 04:07:05 +00:00
36 changed files with 6773 additions and 2890 deletions

1
.npmrc
View File

@@ -1 +0,0 @@
registry=https://registry.npmjs.org

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

68
.serena/project.yml Normal file
View File

@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "tstest"

View File

@@ -1,5 +1,150 @@
# Changelog
## 2025-10-10 - 2.4.1 - fix(runtime/deno)
Enable Deno runtime tests by adding required permissions and local settings
- ts/tstest.classes.runtime.deno.ts: expanded default Deno permissions to include --allow-net, --allow-write and --sloppy-imports to allow network access, file writes and permissive JS/TS imports
- ts/tstest.classes.runtime.deno.ts: updated fallback permissions used when building the Deno command to match the new default set
- Added .claude/settings.local.json with a set of allowed local commands/permissions used for local development/CI tooling
## 2025-10-10 - 2.4.0 - feat(runtime)
Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests
- Introduce RuntimeAdapter abstraction and RuntimeAdapterRegistry to manage multiple runtimes
- Add runtime adapters: NodeRuntimeAdapter, ChromiumRuntimeAdapter, DenoRuntimeAdapter and BunRuntimeAdapter
- Add filename runtime parser utilities: parseTestFilename, isLegacyFilename and getLegacyMigrationTarget
- Add Migration class to detect and (dry-run) migrate legacy test filenames to the new naming convention
- Integrate runtime registry into TsTest and choose execution adapters based on parsed runtimes; show deprecation warnings for legacy naming
- Add tests covering runtime parsing and migration: test/test.runtime.parser.node.ts and test/test.migration.node.ts
## 2025-09-12 - 2.3.8 - fix(tstest)
Improve free port selection for Chrome runner and bump smartnetwork dependency
- Use randomized port selection when finding free HTTP and WebSocket ports to reduce collision probability in concurrent runs
- Ensure WebSocket port search excludes the chosen HTTP port so the two ports will not conflict
- Simplify failure handling: throw early if a free WebSocket port cannot be found instead of retrying with a less robust fallback
- Bump @push.rocks/smartnetwork dependency from ^4.2.0 to ^4.4.0 to pick up new findFreePort options
## 2025-09-12 - 2.3.7 - fix(tests)
Remove flaky dynamic-ports browser test and add local dev tool settings
- Removed test/tapbundle/test.dynamicports.ts — deletes a browser test that relied on injected dynamic WebSocket ports (reduces flaky CI/browser runs).
- Added .claude/settings.local.json — local development settings for the CLAUDE helper (grants allowed dev/automation commands and webfetch permissions).
## 2025-09-03 - 2.3.6 - fix(tstest)
Update deps, fix chrome server route for static bundles, add local tool settings and CI ignore
- Bump devDependency @git.zone/tsbuild to ^2.6.8
- Bump dependencies: @api.global/typedserver to ^3.0.78, @push.rocks/smartlog to ^3.1.9, @push.rocks/smartrequest to ^4.3.1
- Fix test server static route in ts/tstest.classes.tstest.ts: replace '(.*)' with '/*splat' so bundled test files are served correctly in Chromium runs
- Add .claude/settings.local.json with local permissions for development tasks
- Add .serena/.gitignore to ignore /cache
## 2025-08-18 - 2.3.5 - fix(core)
Use SmartRequest with Buffer for binary downloads, tighten static route handling, bump dependencies and add workspace/config files
- ts_tapbundle_node/classes.testfileprovider.ts: switch to SmartRequest.create().url(...).get() and convert response to a Buffer before writing to disk to fix binary download handling for the Docker Alpine image.
- ts/tstest.classes.tstest.ts: change server.addRoute from '*' to '(.*)' so the typedserver static handler uses a proper regex route.
- package.json: bump several dependencies (e.g. @api.global/typedserver, @git.zone/tsbuild, @push.rocks/smartfile, @push.rocks/smartpath, @push.rocks/smartrequest, @push.rocks/smartshell) to newer patch/minor versions.
- pnpm-workspace.yaml: add onlyBuiltDependencies list (esbuild, mongodb-memory-server, puppeteer).
- Remove registry setting from .npmrc (cleanup).
- Add project/agent config files: .serena/project.yml and .claude/settings.local.json for local tooling/agent configuration.
## 2025-08-16 - 2.3.4 - fix(ci)
Add local Claude settings to allow required WebFetch and Bash permissions for local tooling and tests
- Add .claude/settings.local.json to configure allowed permissions for local assistant/automation
- Grants WebFetch access for code.foss.global and www.npmjs.com
- Allows various Bash commands used by local tasks and test runs (mkdir, tsbuild, pnpm, node, tsx, tstest, ls, rm, grep, cat)
- No runtime/library code changes — configuration only
## 2025-08-16 - 2.3.3 - fix(dependencies)
Bump dependency versions and add local Claude settings
- Bumped devDependency @git.zone/tsbuild ^2.6.3 → ^2.6.4
- Updated @git.zone/tsbundle ^2.2.5 → ^2.5.1
- Updated @push.rocks/consolecolor ^2.0.2 → ^2.0.3
- Updated @push.rocks/qenv ^6.1.0 → ^6.1.3
- Updated @push.rocks/smartchok ^1.0.34 → ^1.1.1
- Updated @push.rocks/smartenv ^5.0.12 → ^5.0.13
- Updated @push.rocks/smartfile ^11.2.3 → ^11.2.5
- Updated @push.rocks/smarts3 ^2.2.5 → ^2.2.6
- Updated @push.rocks/smartshell ^3.2.3 → ^3.2.4
- Updated ws ^8.18.2 → ^8.18.3
- Added .claude/settings.local.json for local Claude permissions and tooling (local-only configuration)
## 2025-07-24 - 2.3.2 - fix(tapbundle)
Fix TypeScript IDE warning about tapTools parameter possibly being undefined
- Changed ITestFunction from interface with optional parameter to union type
- Updated test runner to handle both function signatures (with and without tapTools)
- Resolves IDE warnings while maintaining backward compatibility
## 2025-05-26 - 2.3.1 - fix(tapParser/logger)
Fix test duration reporting and summary formatting in TAP parser and logger
- Introduce startTime in TapParser to capture the overall test duration
- Pass computed duration to logger methods in evaluateFinalResult for accurate timing
- Update summary output to format duration in a human-readable way (ms vs. s)
- Add local permission settings configuration to .claude/settings.local.json
## 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

View File

View File

@@ -6,11 +6,11 @@
"gitzone": {
"projectType": "npm",
"module": {
"githost": "gitlab.com",
"gitscope": "gitzone",
"githost": "code.foss.global",
"gitscope": "git.zone",
"gitrepo": "tstest",
"description": "a test utility to run tests that match test/**/*.ts",
"npmPackagename": "@gitzone/tstest",
"npmPackagename": "@git.zone/tstest",
"license": "MIT"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tstest",
"version": "2.1.0",
"version": "2.4.1",
"private": false,
"description": "a test utility to run tests that match test/**/*.ts",
"exports": {
@@ -24,33 +24,35 @@
"buildDocs": "tsdoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.3",
"@git.zone/tsbuild": "^2.6.8",
"@types/node": "^22.15.21"
},
"dependencies": {
"@api.global/typedserver": "^3.0.74",
"@git.zone/tsbundle": "^2.2.5",
"@api.global/typedserver": "^3.0.78",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.3.3",
"@push.rocks/consolecolor": "^2.0.2",
"@push.rocks/qenv": "^6.1.0",
"@push.rocks/consolecolor": "^2.0.3",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartbrowser": "^2.0.8",
"@push.rocks/smartchok": "^1.1.1",
"@push.rocks/smartcrypto": "^2.0.4",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartenv": "^5.0.12",
"@push.rocks/smartenv": "^5.0.13",
"@push.rocks/smartexpect": "^2.5.0",
"@push.rocks/smartfile": "^11.2.3",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartjson": "^5.0.20",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartlog": "^3.1.9",
"@push.rocks/smartmongo": "^2.0.12",
"@push.rocks/smartpath": "^5.0.18",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smarts3": "^2.2.5",
"@push.rocks/smartshell": "^3.2.3",
"@push.rocks/smartrequest": "^4.3.1",
"@push.rocks/smarts3": "^2.2.6",
"@push.rocks/smartshell": "^3.3.0",
"@push.rocks/smarttime": "^4.1.1",
"@types/ws": "^8.18.1",
"figures": "^6.1.0",
"ws": "^8.18.2"
"ws": "^8.18.3"
},
"files": [
"ts/**/*",

5946
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- esbuild
- mongodb-memory-server
- puppeteer

View File

@@ -215,4 +215,109 @@ The Enhanced Communication system has been implemented to provide rich, real-tim
- 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
- 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.

147
readme.md
View File

@@ -1,9 +1,9 @@
# @gitzone/tstest
# @git.zone/tstest
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative!
## Availabililty and Links
* [npmjs.org (npm package)](https://www.npmjs.com/package/@gitzone/tstest)
* [code.foss.global (source)](https://code.foss.global/gitzone/tstest)
* [npmjs.org (npm package)](https://www.npmjs.com/package/@git.zone/tstest)
* [code.foss.global (source)](https://code.foss.global/git.zone/tstest)
## Why tstest?
@@ -27,13 +27,19 @@
- 🔁 **Retry Logic** - Automatically retry failing tests
- 🛠️ **Test Fixtures** - Create reusable test data
- 📦 **Browser-Compatible** - Full browser support with embedded tapbundle
- 👀 **Watch Mode** - Automatically re-run tests on file changes
- 📊 **Real-time Progress** - Live test execution progress updates
- 🎨 **Visual Diffs** - Beautiful side-by-side diffs for failed assertions
- 🔄 **Event-based Reporting** - Real-time test lifecycle events
- ⚙️ **Test Configuration** - Flexible test settings with .tstest.json files
- 🚀 **Protocol V2** - Enhanced TAP protocol with Unicode delimiters
## Installation
```bash
npm install --save-dev @gitzone/tstest
npm install --save-dev @git.zone/tstest
# or with pnpm
pnpm add -D @gitzone/tstest
pnpm add -D @git.zone/tstest
```
## Usage
@@ -73,6 +79,9 @@ tstest "test/unit/*.ts"
| `--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
@@ -203,9 +212,9 @@ tap.only.test('focus on this', async () => {
// Only this test will run
});
// Todo test
tap.todo('implement later', async () => {
// Marked as todo
// Todo test - creates actual test object marked as todo
tap.todo.test('implement later', async () => {
// This test will be counted but marked as todo
});
// Chaining modifiers
@@ -558,6 +567,115 @@ tapWrap.tap.test('wrapped test', async () => {
## 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
Run specific test patterns:
@@ -731,6 +849,19 @@ tstest test/api/endpoints.test.ts --verbose --timeout 60
## Changelog
### Version 1.11.0
- 👀 Added Watch Mode with `--watch`/`-w` flag for automatic test re-runs
- 📊 Implemented real-time test progress updates with event streaming
- 🎨 Added visual diffs for failed assertions with side-by-side comparison
- 🔄 Enhanced event-based test lifecycle reporting
- ⚙️ Added test configuration system with `.tstest.json` files
- 🚀 Implemented Protocol V2 with Unicode delimiters for better TAP parsing
- 🐛 Fixed `tap.todo()` to create proper test objects
- 🐛 Fixed `tap.skip.test()` to correctly create and count test objects
- 🐛 Fixed `tap.only.test()` implementation with `--only` flag support
- 📁 Added settings inheritance for cascading test configuration
- ⏱️ Added debouncing for file change events in watch mode
### Version 1.10.0
- ⏱️ Added `--timeout <seconds>` option for test file timeout protection
- 🎯 Added `--startFrom <n>` and `--stopAt <n>` options for test file range control

View File

@@ -149,11 +149,13 @@ tap.test('performance test', async (toolsArg) => {
## 5. Test Execution Improvements
### 5.2 Watch Mode
### 5.2 Watch Mode ✅ COMPLETED
- Automatically re-run tests on file changes
- Intelligent test selection based on changed files
- Fast feedback loop for development
- Integration with IDE/editor plugins
- Debounced file change detection (300ms)
- Clear console output between runs
- Shows which files triggered re-runs
- Graceful exit with Ctrl+C
- `--watch-ignore` option for excluding patterns
### 5.3 Advanced Test Filtering (Partial) ⚠️
```typescript
@@ -304,14 +306,16 @@ tstest --changed
- Trend analysis
- Flaky test detection
### Known Issues to Fix
- **tap.todo()**: Method exists but has no implementation
- **tap.skip.test()**: Doesn't create test objects, just logs (breaks test count)
### 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
- **Only Tests**: `tap.only.test()` exists but `--only` mode not fully implemented
### Next Recommended Steps
1. Add Watch Mode - high developer value
2. Implement Custom Reporters - important for CI/CD integration
3. Fix known issues: tap.todo() and tap.skip.test() implementations
4. Implement performance benchmarking API
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

View File

@@ -1,56 +0,0 @@
import { tap, expect } from '../../ts_tapbundle/index.js';
tap.test('should show string diff', async () => {
const expected = `Hello World
This is a test
of multiline strings`;
const actual = `Hello World
This is a demo
of multiline strings`;
// This will fail and show a diff
expect(actual).toEqual(expected);
});
tap.test('should show object diff', async () => {
const expected = {
name: 'John',
age: 30,
city: 'New York',
hobbies: ['reading', 'coding']
};
const actual = {
name: 'John',
age: 31,
city: 'Boston',
hobbies: ['reading', 'gaming']
};
// This will fail and show a diff
expect(actual).toEqual(expected);
});
tap.test('should show array diff', async () => {
const expected = [1, 2, 3, 4, 5];
const actual = [1, 2, 3, 5, 6];
// This will fail and show a diff
expect(actual).toEqual(expected);
});
tap.test('should show primitive diff', async () => {
const expected = 42;
const actual = 43;
// This will fail and show a diff
expect(actual).toBe(expected);
});
tap.test('should pass without diff', async () => {
expect(true).toBe(true);
expect('hello').toEqual('hello');
});
tap.start({ throwOnError: false });

View 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();

View File

@@ -1,23 +0,0 @@
import { tap, expect } from '../../ts_tapbundle/index.js';
tap.test('should show string diff', async () => {
const expected = `line 1
line 2
line 3`;
const actual = `line 1
line changed
line 3`;
try {
expect(actual).toEqual(expected);
} catch (e) {
// Expected to fail
}
});
tap.test('should pass', async () => {
expect('hello').toEqual('hello');
});
tap.start({ throwOnError: false });

View 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();

View 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();

111
test/test.migration.node.ts Normal file
View File

@@ -0,0 +1,111 @@
import { expect, tap } from '../ts_tapbundle/index.js';
import { Migration } from '../ts/tstest.classes.migration.js';
import * as plugins from '../ts/tstest.plugins.js';
import * as paths from '../ts/tstest.paths.js';
tap.test('Migration - can initialize', async () => {
const migration = new Migration({
baseDir: process.cwd(),
dryRun: true,
});
expect(migration).toBeInstanceOf(Migration);
});
tap.test('Migration - findLegacyFiles returns empty for no legacy files', async () => {
const migration = new Migration({
baseDir: process.cwd(),
pattern: 'test/test.migration.node.ts', // This file itself, not legacy
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles).toEqual([]);
});
tap.test('Migration - generateReport works', async () => {
const migration = new Migration({
baseDir: process.cwd(),
dryRun: true,
});
const report = await migration.generateReport();
expect(report).toBeTypeOf('string');
expect(report).toContain('Test File Migration Report');
});
tap.test('Migration - detects legacy files when they exist', async () => {
// Create a temporary legacy test file
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles.length).toEqual(1);
expect(legacyFiles[0]).toContain('test.browser.ts');
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
tap.test('Migration - detects both legacy pattern', async () => {
// Create temporary legacy files
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_both');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const browserFile = plugins.path.join(tempDir, 'test.browser.ts');
const bothFile = plugins.path.join(tempDir, 'test.both.ts');
await plugins.smartfile.memory.toFs('// Browser test\nexport default Promise.resolve();', browserFile);
await plugins.smartfile.memory.toFs('// Both test\nexport default Promise.resolve();', bothFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles.length).toEqual(2);
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
tap.test('Migration - dry run does not modify files', async () => {
// Create a temporary legacy test file
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_dryrun');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
verbose: false,
});
const summary = await migration.run();
expect(summary.dryRun).toEqual(true);
expect(summary.totalLegacyFiles).toEqual(1);
expect(summary.migratedCount).toEqual(1); // Dry run still counts as "would migrate"
// Verify original file still exists
const fileExists = await plugins.smartfile.fs.fileExists(legacyFile);
expect(fileExists).toEqual(true);
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
export default tap.start();

View File

@@ -0,0 +1,167 @@
import { expect, tap } from '../ts_tapbundle/index.js';
import { parseTestFilename, isLegacyFilename, getLegacyMigrationTarget } from '../ts/tstest.classes.runtime.parser.js';
tap.test('parseTestFilename - single runtime', async () => {
const parsed = parseTestFilename('test.node.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - chromium runtime', async () => {
const parsed = parseTestFilename('test.chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - multiple runtimes', async () => {
const parsed = parseTestFilename('test.node+chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - deno+bun runtime', async () => {
const parsed = parseTestFilename('test.deno+bun.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['deno', 'bun']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - with nonci modifier', async () => {
const parsed = parseTestFilename('test.chromium.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - multi-runtime with nonci', async () => {
const parsed = parseTestFilename('test.node+chromium.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - legacy browser', async () => {
const parsed = parseTestFilename('test.browser.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - legacy both', async () => {
const parsed = parseTestFilename('test.both.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - legacy browser with nonci', async () => {
const parsed = parseTestFilename('test.browser.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - complex basename', async () => {
const parsed = parseTestFilename('test.some.feature.node.ts');
expect(parsed.baseName).toEqual('test.some.feature');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - default to node when no runtime', async () => {
const parsed = parseTestFilename('test.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - tsx extension', async () => {
const parsed = parseTestFilename('test.chromium.tsx');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('tsx');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - deduplicates runtime tokens', async () => {
const parsed = parseTestFilename('test.node+node.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('isLegacyFilename - detects browser', async () => {
expect(isLegacyFilename('test.browser.ts')).toEqual(true);
});
tap.test('isLegacyFilename - detects both', async () => {
expect(isLegacyFilename('test.both.ts')).toEqual(true);
});
tap.test('isLegacyFilename - rejects new naming', async () => {
expect(isLegacyFilename('test.node.ts')).toEqual(false);
expect(isLegacyFilename('test.chromium.ts')).toEqual(false);
expect(isLegacyFilename('test.node+chromium.ts')).toEqual(false);
});
tap.test('getLegacyMigrationTarget - browser to chromium', async () => {
const target = getLegacyMigrationTarget('test.browser.ts');
expect(target).toEqual('test.chromium.ts');
});
tap.test('getLegacyMigrationTarget - both to node+chromium', async () => {
const target = getLegacyMigrationTarget('test.both.ts');
expect(target).toEqual('test.node+chromium.ts');
});
tap.test('getLegacyMigrationTarget - browser with nonci', async () => {
const target = getLegacyMigrationTarget('test.browser.nonci.ts');
expect(target).toEqual('test.chromium.nonci.ts');
});
tap.test('getLegacyMigrationTarget - both with nonci', async () => {
const target = getLegacyMigrationTarget('test.both.nonci.ts');
expect(target).toEqual('test.node+chromium.nonci.ts');
});
tap.test('getLegacyMigrationTarget - returns null for non-legacy', async () => {
const target = getLegacyMigrationTarget('test.node.ts');
expect(target).toEqual(null);
});
tap.test('parseTestFilename - handles full paths', async () => {
const parsed = parseTestFilename('/path/to/test.node+chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.original).toEqual('test.node+chromium.ts');
});
export default tap.start();

View 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();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tstest',
version: '2.1.0',
version: '2.4.1',
description: 'a test utility to run tests that match test/**/*.ts'
}

View File

@@ -8,6 +8,40 @@ export enum TestExecutionMode {
}
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
const args = process.argv.slice(2);
const logOptions: LogOptions = {};
@@ -16,12 +50,26 @@ export const runCli = async () => {
let startFromFile: number | null = null;
let stopAtFile: number | null = null;
let timeoutSeconds: number | null = null;
let watchMode: boolean = false;
let watchIgnorePatterns: string[] = [];
// Parse options
for (let i = 0; i < args.length; i++) {
const arg = args[i];
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 '-q':
logOptions.quiet = true;
@@ -84,6 +132,18 @@ export const runCli = async () => {
process.exit(1);
}
break;
case '--watch':
case '-w':
watchMode = true;
break;
case '--watch-ignore':
if (i + 1 < args.length) {
watchIgnorePatterns = args[++i].split(',');
} else {
console.error('Error: --watch-ignore requires a comma-separated list of patterns');
process.exit(1);
}
break;
default:
if (!arg.startsWith('-')) {
testPath = arg;
@@ -101,6 +161,7 @@ export const runCli = async () => {
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
console.error('\nUsage: tstest <path> [options]');
console.error('\nOptions:');
console.error(' --version Show version information');
console.error(' --quiet, -q Minimal output');
console.error(' --verbose, -v Verbose output');
console.error(' --no-color Disable colored output');
@@ -110,6 +171,8 @@ export const runCli = async () => {
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);
}
@@ -125,7 +188,12 @@ export const runCli = async () => {
}
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

View File

@@ -0,0 +1,316 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import { parseTestFilename, getLegacyMigrationTarget, isLegacyFilename } from './tstest.classes.runtime.parser.js';
/**
* Migration result for a single file
*/
export interface MigrationResult {
/**
* Original file path
*/
oldPath: string;
/**
* New file path after migration
*/
newPath: string;
/**
* Whether the migration was performed
*/
migrated: boolean;
/**
* Error message if migration failed
*/
error?: string;
}
/**
* Migration summary
*/
export interface MigrationSummary {
/**
* Total number of legacy files found
*/
totalLegacyFiles: number;
/**
* Number of files successfully migrated
*/
migratedCount: number;
/**
* Number of files that failed to migrate
*/
errorCount: number;
/**
* Individual migration results
*/
results: MigrationResult[];
/**
* Whether this was a dry run
*/
dryRun: boolean;
}
/**
* Migration options
*/
export interface MigrationOptions {
/**
* Base directory to search for test files
* Default: process.cwd()
*/
baseDir?: string;
/**
* Glob pattern for finding test files
* Default: '** /*test*.ts' (without space)
*/
pattern?: string;
/**
* Dry run mode - don't actually rename files
* Default: true
*/
dryRun?: boolean;
/**
* Verbose output
* Default: false
*/
verbose?: boolean;
}
/**
* Migration class for renaming legacy test files to new naming convention
*
* Migrations:
* - .browser.ts → .chromium.ts
* - .both.ts → .node+chromium.ts
* - .both.nonci.ts → .node+chromium.nonci.ts
* - .browser.nonci.ts → .chromium.nonci.ts
*/
export class Migration {
private options: Required<MigrationOptions>;
constructor(options: MigrationOptions = {}) {
this.options = {
baseDir: options.baseDir || process.cwd(),
pattern: options.pattern || '**/test*.ts',
dryRun: options.dryRun !== undefined ? options.dryRun : true,
verbose: options.verbose || false,
};
}
/**
* Find all legacy test files in the base directory
*/
async findLegacyFiles(): Promise<string[]> {
const files = await plugins.smartfile.fs.listFileTree(
this.options.baseDir,
this.options.pattern
);
const legacyFiles: string[] = [];
for (const file of files) {
const fileName = plugins.path.basename(file);
if (isLegacyFilename(fileName)) {
const absolutePath = plugins.path.isAbsolute(file)
? file
: plugins.path.join(this.options.baseDir, file);
legacyFiles.push(absolutePath);
}
}
return legacyFiles;
}
/**
* Migrate a single file
*/
private async migrateFile(filePath: string): Promise<MigrationResult> {
const fileName = plugins.path.basename(filePath);
const dirName = plugins.path.dirname(filePath);
try {
// Get the new filename
const newFileName = getLegacyMigrationTarget(fileName);
if (!newFileName) {
return {
oldPath: filePath,
newPath: filePath,
migrated: false,
error: 'File is not a legacy file',
};
}
const newPath = plugins.path.join(dirName, newFileName);
// Check if target file already exists
if (await plugins.smartfile.fs.fileExists(newPath)) {
return {
oldPath: filePath,
newPath,
migrated: false,
error: `Target file already exists: ${newPath}`,
};
}
if (!this.options.dryRun) {
// Check if we're in a git repository
const isGitRepo = await this.isGitRepository(this.options.baseDir);
if (isGitRepo) {
// Use git mv to preserve history
const smartshell = new plugins.smartshell.Smartshell({
executor: 'bash',
pathDirectories: [],
});
const gitCommand = `cd "${this.options.baseDir}" && git mv "${filePath}" "${newPath}"`;
const result = await smartshell.exec(gitCommand);
if (result.exitCode !== 0) {
throw new Error(`git mv failed: ${result.stderr}`);
}
} else {
// Not a git repository - cannot migrate without git
throw new Error('Migration requires a git repository. We have git!');
}
}
return {
oldPath: filePath,
newPath,
migrated: true,
};
} catch (error) {
return {
oldPath: filePath,
newPath: filePath,
migrated: false,
error: error.message,
};
}
}
/**
* Check if a directory is a git repository
*/
private async isGitRepository(dir: string): Promise<boolean> {
try {
const gitDir = plugins.path.join(dir, '.git');
return await plugins.smartfile.fs.isDirectory(gitDir);
} catch {
return false;
}
}
/**
* Run the migration
*/
async run(): Promise<MigrationSummary> {
const legacyFiles = await this.findLegacyFiles();
console.log('');
console.log(cs('='.repeat(60), 'blue'));
console.log(cs('Test File Migration Tool', 'blue'));
console.log(cs('='.repeat(60), 'blue'));
console.log('');
if (this.options.dryRun) {
console.log(cs('🔍 DRY RUN MODE - No files will be modified', 'orange'));
console.log('');
}
console.log(`Found ${legacyFiles.length} legacy test file(s)`);
console.log('');
const results: MigrationResult[] = [];
let migratedCount = 0;
let errorCount = 0;
for (const file of legacyFiles) {
const result = await this.migrateFile(file);
results.push(result);
if (result.migrated) {
migratedCount++;
const oldName = plugins.path.basename(result.oldPath);
const newName = plugins.path.basename(result.newPath);
if (this.options.dryRun) {
console.log(cs(` Would migrate:`, 'cyan'));
} else {
console.log(cs(` ✓ Migrated:`, 'green'));
}
console.log(` ${oldName}`);
console.log(cs(`${newName}`, 'green'));
console.log('');
} else if (result.error) {
errorCount++;
console.log(cs(` ✗ Failed: ${plugins.path.basename(result.oldPath)}`, 'red'));
console.log(cs(` ${result.error}`, 'red'));
console.log('');
}
}
console.log(cs('='.repeat(60), 'blue'));
console.log(`Summary:`);
console.log(` Total legacy files: ${legacyFiles.length}`);
console.log(` Successfully migrated: ${migratedCount}`);
console.log(` Errors: ${errorCount}`);
console.log(cs('='.repeat(60), 'blue'));
if (this.options.dryRun && legacyFiles.length > 0) {
console.log('');
console.log(cs('To apply these changes, run:', 'orange'));
console.log(cs(' tstest migrate --write', 'orange'));
}
console.log('');
return {
totalLegacyFiles: legacyFiles.length,
migratedCount,
errorCount,
results,
dryRun: this.options.dryRun,
};
}
/**
* Create a migration report without performing the migration
*/
async generateReport(): Promise<string> {
const legacyFiles = await this.findLegacyFiles();
let report = '';
report += 'Test File Migration Report\n';
report += '='.repeat(60) + '\n';
report += '\n';
report += `Found ${legacyFiles.length} legacy test file(s)\n`;
report += '\n';
for (const file of legacyFiles) {
const fileName = plugins.path.basename(file);
const newFileName = getLegacyMigrationTarget(fileName);
if (newFileName) {
report += `${fileName}\n`;
report += `${newFileName}\n`;
report += '\n';
}
}
report += '='.repeat(60) + '\n';
return report;
}
}

View File

@@ -0,0 +1,245 @@
import * as plugins from './tstest.plugins.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
import { TapParser } from './tstest.classes.tap.parser.js';
/**
* Runtime-specific configuration options
*/
export interface RuntimeOptions {
/**
* Environment variables to pass to the runtime
*/
env?: Record<string, string>;
/**
* Additional command-line arguments
*/
extraArgs?: string[];
/**
* Working directory for test execution
*/
cwd?: string;
/**
* Timeout in milliseconds (0 = no timeout)
*/
timeout?: number;
}
/**
* Deno-specific configuration options
*/
export interface DenoOptions extends RuntimeOptions {
/**
* Permissions to grant to Deno
* Default: ['--allow-read', '--allow-env']
*/
permissions?: string[];
/**
* Path to deno.json config file
*/
configPath?: string;
/**
* Path to import map file
*/
importMap?: string;
}
/**
* Chromium-specific configuration options
*/
export interface ChromiumOptions extends RuntimeOptions {
/**
* Chromium launch arguments
*/
launchArgs?: string[];
/**
* Headless mode (default: true)
*/
headless?: boolean;
/**
* Port range for HTTP server
*/
portRange?: { min: number; max: number };
}
/**
* Command configuration returned by createCommand()
*/
export interface RuntimeCommand {
/**
* The main command executable (e.g., 'node', 'deno', 'bun')
*/
command: string;
/**
* Command-line arguments
*/
args: string[];
/**
* Environment variables
*/
env?: Record<string, string>;
/**
* Working directory
*/
cwd?: string;
}
/**
* Runtime availability check result
*/
export interface RuntimeAvailability {
/**
* Whether the runtime is available
*/
available: boolean;
/**
* Version string if available
*/
version?: string;
/**
* Error message if not available
*/
error?: string;
}
/**
* Abstract base class for runtime adapters
* Each runtime (Node, Chromium, Deno, Bun) implements this interface
*/
export abstract class RuntimeAdapter {
/**
* Runtime identifier
*/
abstract readonly id: Runtime;
/**
* Human-readable display name
*/
abstract readonly displayName: string;
/**
* Check if this runtime is available on the system
* @returns Availability information including version
*/
abstract checkAvailable(): Promise<RuntimeAvailability>;
/**
* Create the command configuration for executing a test
* @param testFile - Absolute path to the test file
* @param options - Runtime-specific options
* @returns Command configuration
*/
abstract createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand;
/**
* Execute a test file and return a TAP parser
* @param testFile - Absolute path to the test file
* @param index - Test index (for display)
* @param total - Total number of tests (for display)
* @param options - Runtime-specific options
* @returns TAP parser with test results
*/
abstract run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser>;
/**
* Get the default options for this runtime
* Can be overridden by subclasses
*/
protected getDefaultOptions(): RuntimeOptions {
return {
timeout: 0,
extraArgs: [],
env: {},
};
}
/**
* Merge user options with defaults
*/
protected mergeOptions<T extends RuntimeOptions>(userOptions?: T): T {
const defaults = this.getDefaultOptions();
return {
...defaults,
...userOptions,
env: { ...defaults.env, ...userOptions?.env },
extraArgs: [...(defaults.extraArgs || []), ...(userOptions?.extraArgs || [])],
} as T;
}
}
/**
* Registry for runtime adapters
* Manages all available runtime implementations
*/
export class RuntimeAdapterRegistry {
private adapters: Map<Runtime, RuntimeAdapter> = new Map();
/**
* Register a runtime adapter
*/
register(adapter: RuntimeAdapter): void {
this.adapters.set(adapter.id, adapter);
}
/**
* Get an adapter by runtime ID
*/
get(runtime: Runtime): RuntimeAdapter | undefined {
return this.adapters.get(runtime);
}
/**
* Get all registered adapters
*/
getAll(): RuntimeAdapter[] {
return Array.from(this.adapters.values());
}
/**
* Check which runtimes are available on the system
*/
async checkAvailability(): Promise<Map<Runtime, RuntimeAvailability>> {
const results = new Map<Runtime, RuntimeAvailability>();
for (const [runtime, adapter] of this.adapters) {
const availability = await adapter.checkAvailable();
results.set(runtime, availability);
}
return results;
}
/**
* Get adapters for a list of runtimes, in order
* @param runtimes - Ordered list of runtimes
* @returns Adapters in the same order, skipping any that aren't registered
*/
getAdaptersForRuntimes(runtimes: Runtime[]): RuntimeAdapter[] {
const adapters: RuntimeAdapter[] = [];
for (const runtime of runtimes) {
const adapter = this.get(runtime);
if (adapter) {
adapters.push(adapter);
}
}
return adapters;
}
}

View File

@@ -0,0 +1,219 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Bun runtime adapter
* Executes tests using the Bun runtime with native TypeScript support
*/
export class BunRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'bun';
readonly displayName: string = 'Bun';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Bun is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('bun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Bun not found. Install from: https://bun.sh/',
};
}
// Bun version is just the version number
const version = result.stdout.trim();
return {
available: true,
version: `Bun ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Bun test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
const args: string[] = ['run'];
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'bun',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Bun
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':bun', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build Bun command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
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: ${testFile}`, '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;
}
}

View File

@@ -0,0 +1,293 @@
import * as plugins from './tstest.plugins.js';
import * as paths from './tstest.paths.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type ChromiumOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Chromium runtime adapter
* Executes tests in a headless Chromium browser
*/
export class ChromiumRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'chromium';
readonly displayName: string = 'Chromium';
constructor(
private logger: TsTestLogger,
private tsbundleInstance: any, // TsBundle instance from @push.rocks/tsbundle
private smartbrowserInstance: any, // SmartBrowser instance from @push.rocks/smartbrowser
private timeoutSeconds: number | null
) {
super();
}
/**
* Check if Chromium is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
// Check if smartbrowser is available and can start
// The browser binary is usually handled by @push.rocks/smartbrowser
return {
available: true,
version: 'Chromium (via smartbrowser)',
};
} catch (error) {
return {
available: false,
error: error.message || 'Chromium not available',
};
}
}
/**
* Create command configuration for Chromium test execution
* Note: Chromium tests don't use a traditional command, but this satisfies the interface
*/
createCommand(testFile: string, options?: ChromiumOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
return {
command: 'chromium',
args: [],
env: mergedOptions.env,
cwd: mergedOptions.cwd,
};
}
/**
* Find free ports for HTTP server and WebSocket
*/
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
const smartnetwork = new plugins.smartnetwork.SmartNetwork();
// Find random free HTTP port in range 30000-40000 to minimize collision chance
const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!httpPort) {
throw new Error('Could not find a free HTTP port in range 30000-40000');
}
// Find random free WebSocket port, excluding the HTTP port to ensure they're different
const wsPort = await smartnetwork.findFreePort(30000, 40000, {
randomize: true,
exclude: [httpPort]
});
if (!wsPort) {
throw new Error('Could not find a free WebSocket port in range 30000-40000');
}
// Log selected ports for debugging
if (!this.logger.options.quiet) {
console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
}
return { httpPort, wsPort };
}
/**
* Execute a test file in Chromium browser
*/
async run(
testFile: string,
index: number,
total: number,
options?: ChromiumOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
// lets get all our paths sorted
const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
const bundleFileName = testFile.replace('/', '__') + '.js';
const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
// lets bundle the test
await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
bundler: 'esbuild',
});
// Find free ports for HTTP and WebSocket
const { httpPort, wsPort } = await this.findFreePorts();
// lets create a server
const server = new plugins.typedserver.servertools.Server({
cors: true,
port: httpPort,
});
server.addRoute(
'/test',
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
res.type('.html');
res.write(`
<html>
<head>
<script>
globalThis.testdom = true;
globalThis.wsPort = ${wsPort};
</script>
</head>
<body></body>
</html>
`);
res.end();
})
);
server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
await server.start();
// lets handle realtime comms
const tapParser = new TapParser(testFile + ':chrome', this.logger);
const wss = new plugins.ws.WebSocketServer({ port: wsPort });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const messageStr = message.toString();
if (messageStr.startsWith('console:')) {
const [, level, ...messageParts] = messageStr.split(':');
this.logger.browserConsole(messageParts.join(':'), level);
} else {
tapParser.handleTapLog(messageStr);
}
});
});
// lets do the browser bit with timeout handling
await this.smartbrowserInstance.start();
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
async () => {
// lets enable real time comms
const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
await new Promise((resolve) => (ws.onopen = resolve));
// Ensure this function is declared with 'async'
const logStore = [];
const originalLog = console.log;
const originalError = console.error;
// Override console methods to capture the logs
console.log = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalLog(...args);
};
console.error = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalError(...args);
};
const bundleName = new URLSearchParams(window.location.search).get('bundleName');
originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`);
try {
// Dynamically import the test module
const testModule = await import(`/${bundleName}`);
if (testModule && testModule.default && testModule.default instanceof Promise) {
// Execute the exported test function
await testModule.default;
} else if (testModule && testModule.default && typeof testModule.default.then === 'function') {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.');
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
await testModule.default;
} else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log('Using globalThis.tapPromise');
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
await testModule.default;
} else {
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.error('Test module does not export a default promise.');
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log(`We got: ${JSON.stringify(testModule)}`);
}
} catch (err) {
console.error(err);
}
return logStore.join('\n');
}
);
// 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: ${testFile}`, '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(
`${cs('=> ', 'blue')} Stopped ${cs(testFile, 'orange')} chromium instance and server.`
);
// Always evaluate final result (handleTimeout just sets up the test state)
await tapParser.evaluateFinalResult();
return tapParser;
}
}

View File

@@ -0,0 +1,256 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type DenoOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Deno runtime adapter
* Executes tests using the Deno runtime
*/
export class DenoRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'deno';
readonly displayName: string = 'Deno';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Get default Deno options
*/
protected getDefaultOptions(): DenoOptions {
return {
...super.getDefaultOptions(),
permissions: [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--sloppy-imports', // Allow .js imports to resolve to .ts files
],
};
}
/**
* Check if Deno is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('deno --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Deno not found. Install from: https://deno.land/',
};
}
// Parse Deno version from output (first line is "deno X.Y.Z")
const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
return {
available: true,
version: `Deno ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Deno test execution
*/
createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options) as DenoOptions;
const args: string[] = ['run'];
// Add permissions
const permissions = mergedOptions.permissions || [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--sloppy-imports',
];
args.push(...permissions);
// Add config file if specified
if (mergedOptions.configPath) {
args.push('--config', mergedOptions.configPath);
}
// Add import map if specified
if (mergedOptions.importMap) {
args.push('--import-map', mergedOptions.importMap);
}
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'deno',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Deno
*/
async run(
testFile: string,
index: number,
total: number,
options?: DenoOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':deno', this.logger);
const mergedOptions = this.mergeOptions(options) as DenoOptions;
// Build Deno command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
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: ${testFile}`, '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;
}
}

View File

@@ -0,0 +1,222 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Node.js runtime adapter
* Executes tests using tsrun (TypeScript runner for Node.js)
*/
export class NodeRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'node';
readonly displayName: string = 'Node.js';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Node.js and tsrun are available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
// Check Node.js version
const nodeVersion = process.version;
// Check if tsrun is available
const result = await this.smartshellInstance.exec('tsrun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun',
};
}
return {
available: true,
version: nodeVersion,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Node.js test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
// Build tsrun options
const args: string[] = [];
if (process.argv.includes('--web')) {
args.push('--web');
}
// Add any extra args
if (mergedOptions.extraArgs) {
args.push(...mergedOptions.extraArgs);
}
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'tsrun',
args: [testFile, ...args],
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Node.js
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':node', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build tsrun command
let tsrunOptions = '';
if (process.argv.includes('--web')) {
tsrunOptions += ' --web';
}
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
let runCommand = `tsrun ${testFile}${tsrunOptions}`;
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
// If 00init.ts exists, run it first
let loaderPath: string | null = null;
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(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
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 (loaderPath) {
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: ${testFile}`, '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;
}
}

View File

@@ -0,0 +1,211 @@
/**
* Runtime parser for test file naming convention
* Supports: test.runtime1+runtime2.modifier.ts
* Examples:
* - test.node.ts
* - test.chromium.ts
* - test.node+chromium.ts
* - test.deno+bun.ts
* - test.chromium.nonci.ts
*/
export type Runtime = 'node' | 'chromium' | 'deno' | 'bun';
export type Modifier = 'nonci';
export interface ParsedFilename {
baseName: string;
runtimes: Runtime[];
modifiers: Modifier[];
extension: string;
isLegacy: boolean;
original: string;
}
export interface ParserConfig {
strictUnknownRuntime?: boolean; // default: true
defaultRuntimes?: Runtime[]; // default: ['node']
}
const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']);
const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']);
// Legacy mappings for backwards compatibility
const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = {
browser: ['chromium'],
both: ['node', 'chromium'],
};
/**
* Parse a test filename to extract runtimes, modifiers, and detect legacy patterns
* Algorithm: Right-to-left token analysis from the extension
*/
export function parseTestFilename(
filePath: string,
config: ParserConfig = {}
): ParsedFilename {
const strictUnknownRuntime = config.strictUnknownRuntime ?? true;
const defaultRuntimes = config.defaultRuntimes ?? ['node'];
// Extract just the filename from the path
const fileName = filePath.split('/').pop() || filePath;
const original = fileName;
// Step 1: Extract and validate extension
const lastDot = fileName.lastIndexOf('.');
if (lastDot === -1) {
throw new Error(`Invalid test file: no extension found in "${fileName}"`);
}
const extension = fileName.substring(lastDot + 1);
if (!VALID_EXTENSIONS.has(extension)) {
throw new Error(
`Invalid test file extension ".${extension}" in "${fileName}". ` +
`Valid extensions: ${Array.from(VALID_EXTENSIONS).join(', ')}`
);
}
// Step 2: Split remaining basename by dots
const withoutExtension = fileName.substring(0, lastDot);
const tokens = withoutExtension.split('.');
if (tokens.length === 0) {
throw new Error(`Invalid test file: empty basename in "${fileName}"`);
}
// Step 3: Parse from right to left
let isLegacy = false;
const modifiers: Modifier[] = [];
let runtimes: Runtime[] = [];
let runtimeTokenIndex = -1;
// Scan from right to left
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i];
// Check if this is a known modifier
if (KNOWN_MODIFIERS.has(token)) {
modifiers.unshift(token as Modifier);
continue;
}
// Check if this is a legacy runtime token
if (LEGACY_RUNTIME_MAP[token]) {
isLegacy = true;
runtimes = LEGACY_RUNTIME_MAP[token];
runtimeTokenIndex = i;
break;
}
// Check if this is a runtime chain (may contain + separators)
if (token.includes('+')) {
const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean);
const validRuntimes: Runtime[] = [];
const invalidRuntimes: string[] = [];
for (const candidate of runtimeCandidates) {
if (KNOWN_RUNTIMES.has(candidate)) {
// Dedupe: only add if not already in list
if (!validRuntimes.includes(candidate as Runtime)) {
validRuntimes.push(candidate as Runtime);
}
} else {
invalidRuntimes.push(candidate);
}
}
if (invalidRuntimes.length > 0) {
if (strictUnknownRuntime) {
throw new Error(
`Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
`Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}`
);
} else {
console.warn(
`⚠️ Warning: Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
`Defaulting to: ${defaultRuntimes.join('+')}`
);
runtimes = [...defaultRuntimes];
runtimeTokenIndex = i;
break;
}
}
if (validRuntimes.length > 0) {
runtimes = validRuntimes;
runtimeTokenIndex = i;
break;
}
}
// Check if this is a single runtime token
if (KNOWN_RUNTIMES.has(token)) {
runtimes = [token as Runtime];
runtimeTokenIndex = i;
break;
}
// If we've scanned past modifiers and haven't found a runtime, stop looking
if (modifiers.length > 0) {
break;
}
}
// Step 4: Determine base name
// Everything before the runtime token (if found) is the base name
const baseNameTokens = runtimeTokenIndex >= 0 ? tokens.slice(0, runtimeTokenIndex) : tokens;
const baseName = baseNameTokens.join('.');
// Step 5: Apply defaults if no runtime was detected
if (runtimes.length === 0) {
runtimes = [...defaultRuntimes];
}
return {
baseName: baseName || 'test',
runtimes,
modifiers,
extension,
isLegacy,
original,
};
}
/**
* Check if a filename uses legacy naming convention
*/
export function isLegacyFilename(fileName: string): boolean {
const tokens = fileName.split('.');
for (const token of tokens) {
if (LEGACY_RUNTIME_MAP[token]) {
return true;
}
}
return false;
}
/**
* Get the suggested new filename for a legacy filename
*/
export function getLegacyMigrationTarget(fileName: string): string | null {
const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
if (!parsed.isLegacy) {
return null;
}
// Reconstruct filename with new naming
const parts = [parsed.baseName];
if (parsed.runtimes.length > 0) {
parts.push(parsed.runtimes.join('+'));
}
if (parsed.modifiers.length > 0) {
parts.push(...parsed.modifiers);
}
parts.push(parsed.extension);
return parts.join('.');
}

View File

@@ -22,6 +22,7 @@ export class TapParser {
private logger: TsTestLogger;
private protocolParser: ProtocolParser;
private protocolVersion: string | null = null;
private startTime: number;
/**
* the constructor for TapParser
@@ -29,6 +30,7 @@ export class TapParser {
constructor(public fileName: string, logger?: TsTestLogger) {
this.logger = logger;
this.protocolParser = new ProtocolParser();
this.startTime = Date.now();
}
/**
@@ -480,6 +482,7 @@ export class TapParser {
public async evaluateFinalResult() {
this.receivedTests = this.testStore.length;
const duration = Date.now() - this.startTime;
// check wether all tests ran
if (this.expectedTests === this.receivedTests) {
@@ -494,23 +497,23 @@ export class TapParser {
if (!this.expectedTests && this.receivedTests === 0) {
if (this.logger) {
this.logger.error('No tests were defined. Therefore the testfile failed!');
this.logger.testFileEnd(0, 1, 0); // Count as 1 failure
this.logger.testFileEnd(0, 1, duration); // Count as 1 failure
}
} else if (this.expectedTests !== this.receivedTests) {
if (this.logger) {
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
const errorCount = this.getErrorTests().length || 1; // At least 1 error
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0);
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, duration);
}
} else if (this.getErrorTests().length === 0) {
if (this.logger) {
this.logger.tapOutput('All tests are successfull!!!');
this.logger.testFileEnd(this.receivedTests, 0, 0);
this.logger.testFileEnd(this.receivedTests, 0, duration);
}
} else {
if (this.logger) {
this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0);
this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, duration);
}
}
}

View File

@@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js';
import { TsTestLogger } from './tstest.logging.js';
import type { LogOptions } from './tstest.logging.js';
// Runtime adapters
import { parseTestFilename } from './tstest.classes.runtime.parser.js';
import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js';
import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js';
import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js';
import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
export class TsTest {
public testDir: TestDirectory;
public executionMode: TestExecutionMode;
@@ -18,7 +26,6 @@ export class TsTest {
public startFromFile: number | null;
public stopAtFile: number | null;
public timeoutSeconds: number | null;
private timeoutWarningTimer: NodeJS.Timeout | null = null;
public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
@@ -29,6 +36,8 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle();
public runtimeRegistry = new RuntimeAdapterRegistry();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
this.executionMode = executionModeArg;
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
@@ -37,6 +46,20 @@ export class TsTest {
this.startFromFile = startFromFile;
this.stopAtFile = stopAtFile;
this.timeoutSeconds = timeoutSeconds;
// Register runtime adapters
this.runtimeRegistry.register(
new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds)
);
this.runtimeRegistry.register(
new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
}
async run() {
@@ -45,15 +68,6 @@ export class TsTest {
await this.movePreviousLogFiles();
}
// Start timeout warning timer if no timeout was specified
if (this.timeoutSeconds === null) {
this.timeoutWarningTimer = setTimeout(() => {
this.logger.warning('Test is running for more than 1 minute.');
this.logger.warning('Consider using --timeout option to set a timeout for test files.');
this.logger.warning('Example: tstest test --timeout=300 (for 5 minutes)');
}, 60000); // 1 minute
}
const testGroups = await this.testDir.getTestFileGroups();
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
@@ -92,15 +106,80 @@ export class TsTest {
}
}
// Clear the timeout warning timer if it was set
if (this.timeoutWarningTimer) {
clearTimeout(this.timeoutWarningTimer);
this.timeoutWarningTimer = null;
}
tapCombinator.evaluate();
}
public async runWatch(ignorePatterns: string[] = []) {
const smartchokInstance = new plugins.smartchok.Smartchok([this.testDir.cwd]);
console.clear();
this.logger.watchModeStart();
// Initial run
await this.run();
// Set up file watcher
const fileChanges = new Map<string, NodeJS.Timeout>();
const debounceTime = 300; // 300ms debounce
const runTestsAfterChange = async () => {
console.clear();
const changedFiles = Array.from(fileChanges.keys());
fileChanges.clear();
this.logger.watchModeRerun(changedFiles);
await this.run();
this.logger.watchModeWaiting();
};
// Start watching before subscribing to events
await smartchokInstance.start();
// Subscribe to file change events
const changeObservable = await smartchokInstance.getObservableFor('change');
const addObservable = await smartchokInstance.getObservableFor('add');
const unlinkObservable = await smartchokInstance.getObservableFor('unlink');
const handleFileChange = (changedPath: string) => {
// Skip if path matches ignore patterns
if (ignorePatterns.some(pattern => changedPath.includes(pattern))) {
return;
}
// Clear existing timeout for this file if any
if (fileChanges.has(changedPath)) {
clearTimeout(fileChanges.get(changedPath));
}
// Set new timeout for this file
const timeout = setTimeout(() => {
fileChanges.delete(changedPath);
if (fileChanges.size === 0) {
runTestsAfterChange();
}
}, debounceTime);
fileChanges.set(changedPath, timeout);
};
// Subscribe to all relevant events
changeObservable.subscribe(([path]) => handleFileChange(path));
addObservable.subscribe(([path]) => handleFileChange(path));
unlinkObservable.subscribe(([path]) => handleFileChange(path));
this.logger.watchModeWaiting();
// Handle Ctrl+C to exit gracefully
process.on('SIGINT', async () => {
this.logger.watchModeStop();
await smartchokInstance.stop();
process.exit(0);
});
// Keep the process running
await new Promise(() => {}); // This promise never resolves
}
private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
// Check if this file should be skipped based on range
if (this.startFromFile !== null && fileIndex < this.startFromFile) {
@@ -120,29 +199,50 @@ export class TsTest {
}
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
switch (true) {
case process.env.CI && fileNameArg.includes('.nonci.'):
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
break;
case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBrowser);
break;
case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
this.logger.sectionStart('Part 1: Chrome');
const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBothBrowser);
// Parse the filename to determine runtimes and modifiers
const fileName = plugins.path.basename(fileNameArg);
const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
// Check for nonci modifier in CI environment
if (process.env.CI && parsed.modifiers.includes('nonci')) {
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
return;
}
// Show deprecation warning for legacy naming
if (parsed.isLegacy) {
console.warn('');
console.warn(cs('⚠️ DEPRECATION WARNING', 'orange'));
console.warn(cs(` File: ${fileName}`, 'orange'));
console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange'));
console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green'));
console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan'));
console.warn('');
}
// Get adapters for the specified runtimes
const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes);
if (adapters.length === 0) {
this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`);
return;
}
// Execute tests for each runtime
if (adapters.length === 1) {
// Single runtime - no sections needed
const adapter = adapters[0];
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
} else {
// Multiple runtimes - use sections
for (let i = 0; i < adapters.length; i++) {
const adapter = adapters[i];
this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
this.logger.sectionEnd();
this.logger.sectionStart('Part 2: Node');
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBothNode);
this.logger.sectionEnd();
break;
default:
const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserNode);
break;
}
}
}
@@ -201,6 +301,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
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;
@@ -222,6 +335,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
// 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
@@ -236,9 +353,39 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
const smartnetwork = new plugins.smartnetwork.SmartNetwork();
// Find random free HTTP port in range 30000-40000 to minimize collision chance
const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!httpPort) {
throw new Error('Could not find a free HTTP port in range 30000-40000');
}
// Find random free WebSocket port, excluding the HTTP port to ensure they're different
const wsPort = await smartnetwork.findFreePort(30000, 40000, {
randomize: true,
exclude: [httpPort]
});
if (!wsPort) {
throw new Error('Could not find a free WebSocket port in range 30000-40000');
}
// Log selected ports for debugging
if (!this.logger.options.quiet) {
console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
}
return { httpPort, wsPort };
}
public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> {
this.logger.testFileStart(fileNameArg, 'chromium', index, total);
@@ -253,10 +400,13 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
bundler: 'esbuild',
});
// Find free ports for HTTP and WebSocket
const { httpPort, wsPort } = await this.findFreePorts();
// lets create a server
const server = new plugins.typedserver.servertools.Server({
cors: true,
port: 3007,
port: httpPort,
});
server.addRoute(
'/test',
@@ -267,6 +417,7 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
<head>
<script>
globalThis.testdom = true;
globalThis.wsPort = ${wsPort};
</script>
</head>
<body></body>
@@ -275,12 +426,12 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
res.end();
})
);
server.addRoute('*', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
await server.start();
// lets handle realtime comms
const tapParser = new TapParser(fileNameArg + ':chrome', this.logger);
const wss = new plugins.ws.WebSocketServer({ port: 8080 });
const wss = new plugins.ws.WebSocketServer({ port: wsPort });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const messageStr = message.toString();
@@ -297,10 +448,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
await this.smartbrowserInstance.start();
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:3007/test?bundleName=${bundleFileName}`,
`http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
async () => {
// lets enable real time comms
const ws = new WebSocket('ws://localhost:8080');
const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
await new Promise((resolve) => (ws.onopen = resolve));
// Ensure this function is declared with 'async'
@@ -354,6 +505,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
}
);
// 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;
@@ -373,6 +537,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
// 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);
}
@@ -380,6 +548,11 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
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();
@@ -417,10 +590,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
try {
// Delete 00err and 00diff directories if they exist
if (await plugins.smartfile.fs.isDirectory(errDir)) {
if (plugins.smartfile.fs.isDirectorySync(errDir)) {
plugins.smartfile.fs.removeSync(errDir);
}
if (await plugins.smartfile.fs.isDirectory(diffDir)) {
if (plugins.smartfile.fs.isDirectorySync(diffDir)) {
plugins.smartfile.fs.removeSync(diffDir);
}

View File

@@ -242,9 +242,13 @@ export class TsTestLogger {
if (!this.options.quiet) {
const total = passed + failed;
const status = failed === 0 ? 'PASSED' : 'FAILED';
const color = failed === 0 ? 'green' : 'red';
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
if (failed === 0) {
this.log(this.format(` Summary: ${passed}/${total} PASSED in ${durationStr}`, 'green'));
} else {
this.log(this.format(` Summary: ${passed} passed, ${failed} failed of ${total} tests in ${durationStr}`, 'red'));
}
}
// If using --logfile, handle error copy and diff detection
@@ -390,7 +394,13 @@ export class TsTestLogger {
if (this.options.quiet) {
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
const durationStr = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
if (summary.totalFailed === 0) {
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${durationStr} | ${status}`);
} else {
this.log(`\nSummary: ${summary.totalPassed} passed, ${summary.totalFailed} failed of ${summary.totalTests} tests | ${durationStr} | ${status}`);
}
return;
}
@@ -404,7 +414,8 @@ export class TsTestLogger {
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'));
const durationStrFormatted = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
this.log(this.format(`│ Duration: ${durationStrFormatted.padStart(14)}`, 'white'));
this.log(this.format('└────────────────────────────────┘', 'dim'));
// File results
@@ -425,15 +436,23 @@ export class TsTestLogger {
// Performance metrics
if (this.options.verbose) {
const avgDuration = Math.round(totalDuration / summary.totalTests);
const slowestTest = this.fileResults
.flatMap(r => r.tests)
.sort((a, b) => b.duration - a.duration)[0];
// Calculate metrics based on actual test durations
const allTests = this.fileResults.flatMap(r => r.tests);
const testDurations = allTests.map(t => t.duration);
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(` Average per test: ${avgDuration}ms`, 'white'));
if (slowestTest) {
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'yellow'));
this.log(this.format(` Average per test: ${avgTestDuration}ms`, 'white'));
if (slowestTest && slowestTest.duration > 0) {
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'));
}
}
@@ -520,4 +539,47 @@ export class TsTestLogger {
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'));
}
}

View File

@@ -13,9 +13,11 @@ export {
// @push.rocks scope
import * as consolecolor from '@push.rocks/consolecolor';
import * as smartbrowser from '@push.rocks/smartbrowser';
import * as smartchok from '@push.rocks/smartchok';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartfile from '@push.rocks/smartfile';
import * as smartlog from '@push.rocks/smartlog';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartshell from '@push.rocks/smartshell';
import * as tapbundle from '../dist_ts_tapbundle/index.js';
@@ -23,15 +25,17 @@ import * as tapbundle from '../dist_ts_tapbundle/index.js';
export {
consolecolor,
smartbrowser,
smartchok,
smartdelay,
smartfile,
smartlog,
smartnetwork,
smartpromise,
smartshell,
tapbundle,
};
// @gitzone scope
// @git.zone scope
import * as tsbundle from '@git.zone/tsbundle';
export { tsbundle };

View File

@@ -11,9 +11,9 @@ import { HrtMeasurement } from '@push.rocks/smarttime';
// interfaces
export type TTestStatus = 'success' | 'error' | 'pending' | 'errorAfterSuccess' | 'timeout' | 'skipped';
export interface ITestFunction<T> {
(tapTools?: TapTools): Promise<T>;
}
export type ITestFunction<T> =
| ((tapTools: TapTools) => Promise<T>)
| (() => Promise<T>);
export class TapTest<T = unknown> {
public description: string;
@@ -173,7 +173,9 @@ export class TapTest<T = unknown> {
}
// Run the test function with potential timeout
const testPromise = this.testFunction(this.tapTools);
const testPromise = this.testFunction.length === 0
? (this.testFunction as () => Promise<T>)()
: (this.testFunction as (tapTools: TapTools) => Promise<T>)(this.tapTools);
const testReturnValue = timeoutPromise
? await Promise.race([testPromise, timeoutPromise])
: await testPromise;

View File

@@ -9,9 +9,12 @@ export class TestFileProvider {
public async getDockerAlpineImageAsLocalTarball(): Promise<string> {
const filePath = plugins.path.join(paths.testFilesDir, 'alpine.tar')
// fetch the docker alpine image
const response = await plugins.smartrequest.getBinary(fileUrls.dockerAlpineImage);
const response = await plugins.smartrequest.SmartRequest.create()
.url(fileUrls.dockerAlpineImage)
.get();
await plugins.smartfile.fs.ensureDir(paths.testFilesDir);
await plugins.smartfile.memory.toFs(response.body, filePath);
const buffer = Buffer.from(await response.arrayBuffer());
await plugins.smartfile.memory.toFs(buffer, filePath);
return filePath;
}
}

View File

@@ -269,8 +269,8 @@ export class ProtocolParser {
// Extract simple key:value pairs
const simpleMatch = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]+)${this.escapeRegex(PROTOCOL_MARKERS.END)}`));
if (simpleMatch && !simpleMatch[1].includes(':')) {
// Not a prefixed format, might be key:value pairs
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(':');