Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
4714d5e8ad | |||
ff6aae7159 | |||
d05ec21b73 | |||
956a880a4a | |||
ee11b1ac17 | |||
054cbb6b3c | |||
ecf11efb4c | |||
1de674e91d | |||
9fa2c23ab2 | |||
36715c9139 | |||
ee0aca9ff7 | |||
aaebe75326 | |||
265ed702ee | |||
efbaded1f3 | |||
799a60188f | |||
3c38a53d9d | |||
cca01b51ec | |||
84843ad359 | |||
7a8ae95be2 | |||
133e0eda8b | |||
14e32b06de | |||
48aebb1eac | |||
733b2249d0 | |||
008844a9e2 | |||
e4fc6623ea | |||
70435cce45 | |||
c26145205f | |||
82fc22653b | |||
3d85f54be0 | |||
9464c17c15 | |||
91b99ce304 | |||
899045e6aa | |||
845f146e91 | |||
d1f8652fc7 | |||
f717078558 | |||
d2c0e533b5 | |||
d3c7fce595 | |||
570e2d6b3b | |||
b7f4b7b3b8 | |||
424046b0de | |||
0f762f2063 | |||
82757c4abc | |||
7aaeed0dc6 |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal 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"
|
171
changelog.md
171
changelog.md
@@ -1,5 +1,176 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-10-12 - 2.5.0 - feat(tstest.classes.runtime.parser)
|
||||||
|
Add support for "all" runtime token and update docs/tests; regenerate lockfile and add local settings
|
||||||
|
|
||||||
|
- Add support for the `all` runtime token (expands to node, chromium, deno, bun) in tstest filename parser (tstest.classes.runtime.parser)
|
||||||
|
- Handle `all` with modifiers (e.g. `*.all.nonci.ts`) and mixed tokens (e.g. `node+all`) so it expands to the full runtime set
|
||||||
|
- Add unit tests covering `all` cases in test/test.runtime.parser.node.ts
|
||||||
|
- Update README (examples and tables) to document `.all.ts` and `.all.nonci.ts` usage and include a universal example
|
||||||
|
- Update ts files' parser comments and constants to include ALL_RUNTIMES
|
||||||
|
- Add deno.lock (dependency lockfile) and a local .claude/settings.local.json for project permissions / local settings
|
||||||
|
|
||||||
|
## 2025-10-11 - 2.4.3 - fix(docs)
|
||||||
|
Update documentation: expand README with multi-runtime architecture, add module READMEs, and add local dev settings
|
||||||
|
|
||||||
|
- Expanded project README: fixed typos, clarified availability header, and added a detailed Multi-Runtime Architecture section (runtimes, naming conventions, migration tool, examples, and runtime-specific notes).
|
||||||
|
- Inserted additional example output and adjusted JSON/example sections to reflect multi-runtime flows and updated totals/durations in examples.
|
||||||
|
- Added dedicated README files for ts_tapbundle, ts_tapbundle_node, and ts_tapbundle_protocol modules with API overviews and usage guides.
|
||||||
|
- Added .claude/settings.local.json to provide local development permissions/settings used by the project tooling.
|
||||||
|
- Minor formatting and documentation cleanup (whitespace, headings, and changelog entries).
|
||||||
|
|
||||||
|
## 2025-10-10 - 2.4.2 - fix(deno)
|
||||||
|
Enable additional Deno permissions for runtime adapters and add local dev settings
|
||||||
|
|
||||||
|
- Add --allow-sys, --allow-import and --node-modules-dir to the default Deno permission set used by the Deno runtime adapter
|
||||||
|
- Include the new permission flags in the fallback permissions array when constructing Deno command args
|
||||||
|
- Add .claude/settings.local.json to capture local development permissions and helper commands
|
||||||
|
|
||||||
|
## 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)
|
## 2025-05-26 - 2.1.0 - feat(core)
|
||||||
Implement Protocol V2 with enhanced settings and lifecycle hooks
|
Implement Protocol V2 with enhanced settings and lifecycle hooks
|
||||||
|
|
||||||
|
@@ -6,11 +6,11 @@
|
|||||||
"gitzone": {
|
"gitzone": {
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "gitlab.com",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "gitzone",
|
"gitscope": "git.zone",
|
||||||
"gitrepo": "tstest",
|
"gitrepo": "tstest",
|
||||||
"description": "a test utility to run tests that match test/**/*.ts",
|
"description": "a test utility to run tests that match test/**/*.ts",
|
||||||
"npmPackagename": "@gitzone/tstest",
|
"npmPackagename": "@git.zone/tstest",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tstest",
|
"name": "@git.zone/tstest",
|
||||||
"version": "2.1.0",
|
"version": "2.5.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a test utility to run tests that match test/**/*.ts",
|
"description": "a test utility to run tests that match test/**/*.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -24,33 +24,35 @@
|
|||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.3",
|
"@git.zone/tsbuild": "^2.6.8",
|
||||||
"@types/node": "^22.15.21"
|
"@types/node": "^22.15.21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedserver": "^3.0.74",
|
"@api.global/typedserver": "^3.0.78",
|
||||||
"@git.zone/tsbundle": "^2.2.5",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@push.rocks/consolecolor": "^2.0.2",
|
"@push.rocks/consolecolor": "^2.0.3",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartbrowser": "^2.0.8",
|
"@push.rocks/smartbrowser": "^2.0.8",
|
||||||
|
"@push.rocks/smartchok": "^1.1.1",
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartenv": "^5.0.12",
|
"@push.rocks/smartenv": "^5.0.13",
|
||||||
"@push.rocks/smartexpect": "^2.5.0",
|
"@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/smartjson": "^5.0.20",
|
||||||
"@push.rocks/smartlog": "^3.1.8",
|
"@push.rocks/smartlog": "^3.1.9",
|
||||||
"@push.rocks/smartmongo": "^2.0.12",
|
"@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/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.1.0",
|
"@push.rocks/smartrequest": "^4.3.1",
|
||||||
"@push.rocks/smarts3": "^2.2.5",
|
"@push.rocks/smarts3": "^2.2.6",
|
||||||
"@push.rocks/smartshell": "^3.2.3",
|
"@push.rocks/smartshell": "^3.3.0",
|
||||||
"@push.rocks/smarttime": "^4.1.1",
|
"@push.rocks/smarttime": "^4.1.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"ws": "^8.18.2"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
5946
pnpm-lock.yaml
generated
5946
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- mongodb-memory-server
|
||||||
|
- puppeteer
|
105
readme.hints.md
105
readme.hints.md
@@ -216,3 +216,108 @@ The Enhanced Communication system has been implemented to provide rich, real-tim
|
|||||||
- Event data is JSON-encoded within protocol markers
|
- Event data is JSON-encoded within protocol markers
|
||||||
- Parser handles events asynchronously for real-time updates
|
- 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.
|
308
readme.md
308
readme.md
@@ -1,9 +1,9 @@
|
|||||||
# @gitzone/tstest
|
# @git.zone/tstest
|
||||||
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative!
|
🧪 **A powerful, modern test runner for TypeScript** - making your test runs beautiful and informative across multiple runtimes!
|
||||||
|
|
||||||
## Availabililty and Links
|
## Availability and Links
|
||||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@gitzone/tstest)
|
* [npmjs.org (npm package)](https://www.npmjs.com/package/@git.zone/tstest)
|
||||||
* [code.foss.global (source)](https://code.foss.global/gitzone/tstest)
|
* [code.foss.global (source)](https://code.foss.global/git.zone/tstest)
|
||||||
|
|
||||||
## Why tstest?
|
## Why tstest?
|
||||||
|
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
### ✨ Key Features
|
### ✨ Key Features
|
||||||
|
|
||||||
- 🎯 **Smart Test Execution** - Run all tests, single files, or use glob patterns
|
- 🎯 **Smart Test Execution** - Run all tests, single files, or use glob patterns
|
||||||
|
- 🚀 **Multi-Runtime Support** - Run tests in Node.js, Deno, Bun, and Chromium
|
||||||
- 🎨 **Beautiful Output** - Color-coded results with emojis and clean formatting
|
- 🎨 **Beautiful Output** - Color-coded results with emojis and clean formatting
|
||||||
- 📊 **Multiple Output Modes** - Choose from normal, quiet, verbose, or JSON output
|
- 📊 **Multiple Output Modes** - Choose from normal, quiet, verbose, or JSON output
|
||||||
- 🔍 **Automatic Discovery** - Finds all your test files automatically
|
- 🔍 **Automatic Discovery** - Finds all your test files automatically
|
||||||
- 🌐 **Cross-Environment** - Supports Node.js and browser testing
|
|
||||||
- 📝 **Detailed Logging** - Optional file logging for debugging
|
- 📝 **Detailed Logging** - Optional file logging for debugging
|
||||||
- ⚡ **Performance Metrics** - See which tests are slow
|
- ⚡ **Performance Metrics** - See which tests are slow
|
||||||
- 🤖 **CI/CD Ready** - JSON output mode for automation
|
- 🤖 **CI/CD Ready** - JSON output mode for automation
|
||||||
@@ -26,16 +26,167 @@
|
|||||||
- ⏳ **Timeout Control** - Set custom timeouts for tests
|
- ⏳ **Timeout Control** - Set custom timeouts for tests
|
||||||
- 🔁 **Retry Logic** - Automatically retry failing tests
|
- 🔁 **Retry Logic** - Automatically retry failing tests
|
||||||
- 🛠️ **Test Fixtures** - Create reusable test data
|
- 🛠️ **Test Fixtures** - Create reusable test data
|
||||||
- 📦 **Browser-Compatible** - Full browser support with embedded tapbundle
|
- 👀 **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
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --save-dev @gitzone/tstest
|
npm install --save-dev @git.zone/tstest
|
||||||
# or with pnpm
|
# or with pnpm
|
||||||
pnpm add -D @gitzone/tstest
|
pnpm add -D @git.zone/tstest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-Runtime Architecture
|
||||||
|
|
||||||
|
tstest supports running your tests across multiple JavaScript runtimes, allowing you to verify cross-platform compatibility easily.
|
||||||
|
|
||||||
|
### Supported Runtimes
|
||||||
|
|
||||||
|
- **Node.js** - Default runtime, uses tsrun for TypeScript execution
|
||||||
|
- **Chromium** - Browser environment testing with full DOM support
|
||||||
|
- **Deno** - Secure TypeScript/JavaScript runtime with modern features
|
||||||
|
- **Bun** - Ultra-fast all-in-one JavaScript runtime
|
||||||
|
|
||||||
|
### Test File Naming Convention
|
||||||
|
|
||||||
|
Name your test files with runtime specifiers to control where they run:
|
||||||
|
|
||||||
|
| Pattern | Runtimes | Example |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| `*.ts` | Node.js only (default) | `test.api.ts` |
|
||||||
|
| `*.node.ts` | Node.js only | `test.server.node.ts` |
|
||||||
|
| `*.chromium.ts` | Chromium browser | `test.dom.chromium.ts` |
|
||||||
|
| `*.deno.ts` | Deno runtime | `test.http.deno.ts` |
|
||||||
|
| `*.bun.ts` | Bun runtime | `test.fast.bun.ts` |
|
||||||
|
| `*.all.ts` | All runtimes (Node, Chromium, Deno, Bun) | `test.universal.all.ts` |
|
||||||
|
| `*.node+chromium.ts` | Both Node.js and Chromium | `test.isomorphic.node+chromium.ts` |
|
||||||
|
| `*.node+deno.ts` | Both Node.js and Deno | `test.cross.node+deno.ts` |
|
||||||
|
| `*.deno+bun.ts` | Both Deno and Bun | `test.modern.deno+bun.ts` |
|
||||||
|
| `*.chromium.nonci.ts` | Chromium, skip in CI | `test.visual.chromium.nonci.ts` |
|
||||||
|
| `*.all.nonci.ts` | All runtimes, skip in CI | `test.comprehensive.all.nonci.ts` |
|
||||||
|
|
||||||
|
**Multi-Runtime Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// test.api.all.ts - runs in all runtimes (Node, Chromium, Deno, Bun)
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('universal HTTP test', async () => {
|
||||||
|
const response = await fetch('https://api.example.com/test');
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// test.api.node+deno+bun.ts - runs in specific runtimes
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('cross-runtime HTTP test', async () => {
|
||||||
|
const response = await fetch('https://api.example.com/test');
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Execution Order
|
||||||
|
|
||||||
|
When multiple runtimes are specified, tests execute in this order:
|
||||||
|
1. Node.js
|
||||||
|
2. Bun
|
||||||
|
3. Deno
|
||||||
|
4. Chromium
|
||||||
|
|
||||||
|
### Legacy Naming (Deprecated)
|
||||||
|
|
||||||
|
The following patterns are still supported but deprecated. Use the migration tool to update:
|
||||||
|
|
||||||
|
| Legacy Pattern | Modern Equivalent | Migration Command |
|
||||||
|
|----------------|-------------------|-------------------|
|
||||||
|
| `*.browser.ts` | `*.chromium.ts` | `tstest migrate` |
|
||||||
|
| `*.both.ts` | `*.node+chromium.ts` | `tstest migrate` |
|
||||||
|
|
||||||
|
When running legacy files, tstest shows a deprecation warning with the suggested new name.
|
||||||
|
|
||||||
|
### Migration Tool
|
||||||
|
|
||||||
|
Migrate your test files from legacy naming to the new convention:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry run - see what would change
|
||||||
|
tstest migrate --dry-run
|
||||||
|
|
||||||
|
# Apply migrations (uses git mv to preserve history)
|
||||||
|
tstest migrate --write
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration Features:**
|
||||||
|
- ✅ Uses `git mv` to preserve file history
|
||||||
|
- ✅ Idempotent - safe to run multiple times
|
||||||
|
- ✅ Dry-run by default for safety
|
||||||
|
- ✅ Colored output showing all changes
|
||||||
|
- ✅ Handles modifiers like `.nonci` correctly
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
```
|
||||||
|
============================================================
|
||||||
|
Test File Migration Tool
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
🔍 DRY RUN MODE - No files will be modified
|
||||||
|
|
||||||
|
Found 3 legacy test file(s)
|
||||||
|
|
||||||
|
Would migrate:
|
||||||
|
test.browser.ts
|
||||||
|
→ test.chromium.ts
|
||||||
|
|
||||||
|
Would migrate:
|
||||||
|
test.both.ts
|
||||||
|
→ test.node+chromium.ts
|
||||||
|
|
||||||
|
Would migrate:
|
||||||
|
test.auth.browser.nonci.ts
|
||||||
|
→ test.auth.chromium.nonci.ts
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
Summary:
|
||||||
|
Total legacy files: 3
|
||||||
|
Successfully migrated: 3
|
||||||
|
Errors: 0
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
To apply these changes, run:
|
||||||
|
tstest migrate --write
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime-Specific Permissions
|
||||||
|
|
||||||
|
#### Deno Runtime
|
||||||
|
|
||||||
|
Tests run with these permissions by default:
|
||||||
|
```bash
|
||||||
|
--allow-read
|
||||||
|
--allow-env
|
||||||
|
--allow-net
|
||||||
|
--allow-write
|
||||||
|
--allow-sys
|
||||||
|
--allow-import # Enables npm packages and Node.js built-ins
|
||||||
|
--node-modules-dir # Node.js compatibility mode
|
||||||
|
--sloppy-imports # Allows .js imports to resolve to .ts files
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure custom permissions in your test file or via environment variables.
|
||||||
|
|
||||||
|
#### Bun Runtime
|
||||||
|
|
||||||
|
Bun runs with its native TypeScript support and full access to Node.js APIs.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Test Execution
|
### Basic Test Execution
|
||||||
@@ -73,6 +224,9 @@ tstest "test/unit/*.ts"
|
|||||||
| `--timeout <seconds>` | Timeout test files after specified seconds |
|
| `--timeout <seconds>` | Timeout test files after specified seconds |
|
||||||
| `--startFrom <n>` | Start running from test file number n |
|
| `--startFrom <n>` | Start running from test file number n |
|
||||||
| `--stopAt <n>` | Stop running at test file number n |
|
| `--stopAt <n>` | Stop running at test file number n |
|
||||||
|
| `--watch`, `-w` | Watch for file changes and re-run tests |
|
||||||
|
| `--watch-ignore <patterns>` | Ignore patterns in watch mode (comma-separated) |
|
||||||
|
| `--only` | Run only tests marked with .only |
|
||||||
|
|
||||||
### Example Outputs
|
### Example Outputs
|
||||||
|
|
||||||
@@ -83,18 +237,29 @@ tstest "test/unit/*.ts"
|
|||||||
Pattern: test
|
Pattern: test
|
||||||
Found: 4 test file(s)
|
Found: 4 test file(s)
|
||||||
|
|
||||||
▶️ test/test.ts (1/4)
|
━━━ Part 1: Node.js ━━━
|
||||||
Runtime: node.js
|
|
||||||
✅ prepare test (1ms)
|
▶️ test/test.node+deno.ts (1/4)
|
||||||
Summary: 1/1 PASSED
|
Runtime: Node.js
|
||||||
|
✅ HTTP request works (12ms)
|
||||||
|
✅ JSON parsing works (3ms)
|
||||||
|
Summary: 2/2 PASSED in 1.2s
|
||||||
|
|
||||||
|
━━━ Part 2: Deno ━━━
|
||||||
|
|
||||||
|
▶️ test/test.node+deno.ts (1/4)
|
||||||
|
Runtime: Deno
|
||||||
|
✅ HTTP request works (15ms)
|
||||||
|
✅ JSON parsing works (2ms)
|
||||||
|
Summary: 2/2 PASSED in 1.1s
|
||||||
|
|
||||||
📊 Test Summary
|
📊 Test Summary
|
||||||
┌────────────────────────────────┐
|
┌────────────────────────────────┐
|
||||||
│ Total Files: 4 │
|
│ Total Files: 4 │
|
||||||
│ Total Tests: 4 │
|
│ Total Tests: 8 │
|
||||||
│ Passed: 4 │
|
│ Passed: 8 │
|
||||||
│ Failed: 0 │
|
│ Failed: 0 │
|
||||||
│ Duration: 542ms │
|
│ Duration: 2.4s │
|
||||||
└────────────────────────────────┘
|
└────────────────────────────────┘
|
||||||
|
|
||||||
ALL TESTS PASSED! 🎉
|
ALL TESTS PASSED! 🎉
|
||||||
@@ -132,19 +297,7 @@ Perfect for CI/CD pipelines:
|
|||||||
{"event":"summary","summary":{"totalFiles":4,"totalTests":4,"totalPassed":4,"totalFailed":0,"totalDuration":542}}
|
{"event":"summary","summary":{"totalFiles":4,"totalTests":4,"totalPassed":4,"totalFailed":0,"totalDuration":542}}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test File Naming Conventions
|
## Writing Tests with tapbundle
|
||||||
|
|
||||||
tstest supports different test environments through file naming:
|
|
||||||
|
|
||||||
| Pattern | Environment | Example |
|
|
||||||
|---------|-------------|---------|
|
|
||||||
| `*.ts` | Node.js (default) | `test.basic.ts` |
|
|
||||||
| `*.node.ts` | Node.js only | `test.api.node.ts` |
|
|
||||||
| `*.chrome.ts` | Chrome browser | `test.dom.chrome.ts` |
|
|
||||||
| `*.browser.ts` | Browser environment | `test.ui.browser.ts` |
|
|
||||||
| `*.both.ts` | Both Node.js and browser | `test.isomorphic.both.ts` |
|
|
||||||
|
|
||||||
### Writing Tests with tapbundle
|
|
||||||
|
|
||||||
tstest includes tapbundle, a powerful TAP-based test framework. Import it from the embedded tapbundle:
|
tstest includes tapbundle, a powerful TAP-based test framework. Import it from the embedded tapbundle:
|
||||||
|
|
||||||
@@ -156,7 +309,7 @@ tap.test('my awesome test', async () => {
|
|||||||
expect(result).toEqual('expected value');
|
expect(result).toEqual('expected value');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
export default tap.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Module Exports**
|
**Module Exports**
|
||||||
@@ -187,7 +340,7 @@ tap.test('async operations', async (tools) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start test execution
|
// Start test execution
|
||||||
tap.start();
|
export default tap.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Modifiers and Chaining
|
### Test Modifiers and Chaining
|
||||||
@@ -203,9 +356,9 @@ tap.only.test('focus on this', async () => {
|
|||||||
// Only this test will run
|
// Only this test will run
|
||||||
});
|
});
|
||||||
|
|
||||||
// Todo test
|
// Todo test - creates actual test object marked as todo
|
||||||
tap.todo('implement later', async () => {
|
tap.todo.test('implement later', async () => {
|
||||||
// Marked as todo
|
// This test will be counted but marked as todo
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chaining modifiers
|
// Chaining modifiers
|
||||||
@@ -558,21 +711,63 @@ tapWrap.tap.test('wrapped test', async () => {
|
|||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
### Glob Pattern Support
|
### Watch Mode
|
||||||
|
|
||||||
|
Automatically re-run tests when files change:
|
||||||
|
|
||||||
Run specific test patterns:
|
|
||||||
```bash
|
```bash
|
||||||
# Run all unit tests
|
# Watch all files in the project
|
||||||
tstest "test/unit/**/*.ts"
|
tstest test/ --watch
|
||||||
|
|
||||||
# Run all integration tests
|
# Watch with custom ignore patterns
|
||||||
tstest "test/integration/*.test.ts"
|
tstest test/ --watch --watch-ignore "dist/**,coverage/**"
|
||||||
|
|
||||||
# Run multiple patterns
|
# Short form
|
||||||
tstest "test/**/*.spec.ts" "test/**/*.test.ts"
|
tstest test/ -w
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important**: Always quote glob patterns to prevent shell expansion. Without quotes, the shell will expand the pattern and only pass the first matching file to tstest.
|
**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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Enhanced Test Logging
|
### Enhanced Test Logging
|
||||||
|
|
||||||
@@ -731,6 +926,31 @@ tstest test/api/endpoints.test.ts --verbose --timeout 60
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### Version 2.4.0
|
||||||
|
- 🚀 **Multi-Runtime Architecture** - Support for Deno, Bun, Node.js, and Chromium
|
||||||
|
- 🔀 **New Naming Convention** - Flexible `.runtime1+runtime2.ts` pattern
|
||||||
|
- 🌐 **Universal Testing** - `.all.ts` pattern runs tests on all supported runtimes
|
||||||
|
- 🔄 **Migration Tool** - Easy migration from legacy naming (`.browser.ts`, `.both.ts`)
|
||||||
|
- 🦕 **Deno Support** - Full Deno runtime with Node.js compatibility
|
||||||
|
- 🐰 **Bun Support** - Ultra-fast Bun runtime integration
|
||||||
|
- ⚡ **Dynamic Port Selection** - Random port allocation (30000-40000) prevents conflicts
|
||||||
|
- 🏗️ **Runtime Adapter Pattern** - Extensible architecture for adding new runtimes
|
||||||
|
- 📝 **Deprecation Warnings** - Helpful migration suggestions for legacy naming
|
||||||
|
- ✅ **Comprehensive Tests** - Full test coverage for parser and migration tool
|
||||||
|
|
||||||
|
### 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
|
### Version 1.10.0
|
||||||
- ⏱️ Added `--timeout <seconds>` option for test file timeout protection
|
- ⏱️ Added `--timeout <seconds>` option for test file timeout protection
|
||||||
- 🎯 Added `--startFrom <n>` and `--stopAt <n>` options for test file range control
|
- 🎯 Added `--startFrom <n>` and `--stopAt <n>` options for test file range control
|
||||||
@@ -770,7 +990,7 @@ tstest test/api/endpoints.test.ts --verbose --timeout 60
|
|||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license.md) file within this repository.
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
@@ -149,11 +149,13 @@ tap.test('performance test', async (toolsArg) => {
|
|||||||
## 5. Test Execution Improvements
|
## 5. Test Execution Improvements
|
||||||
|
|
||||||
|
|
||||||
### 5.2 Watch Mode
|
### 5.2 Watch Mode ✅ COMPLETED
|
||||||
- Automatically re-run tests on file changes
|
- Automatically re-run tests on file changes
|
||||||
- Intelligent test selection based on changed files
|
- Debounced file change detection (300ms)
|
||||||
- Fast feedback loop for development
|
- Clear console output between runs
|
||||||
- Integration with IDE/editor plugins
|
- Shows which files triggered re-runs
|
||||||
|
- Graceful exit with Ctrl+C
|
||||||
|
- `--watch-ignore` option for excluding patterns
|
||||||
|
|
||||||
### 5.3 Advanced Test Filtering (Partial) ⚠️
|
### 5.3 Advanced Test Filtering (Partial) ⚠️
|
||||||
```typescript
|
```typescript
|
||||||
@@ -304,14 +306,16 @@ tstest --changed
|
|||||||
- Trend analysis
|
- Trend analysis
|
||||||
- Flaky test detection
|
- Flaky test detection
|
||||||
|
|
||||||
### Known Issues to Fix
|
### Recently Fixed Issues ✅
|
||||||
- **tap.todo()**: Method exists but has no implementation
|
- **tap.todo()**: Now fully implemented with test object creation
|
||||||
- **tap.skip.test()**: Doesn't create test objects, just logs (breaks test count)
|
- **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
|
- **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
|
### Next Recommended Steps
|
||||||
1. Add Watch Mode - high developer value
|
1. Add Watch Mode (Phase 4) - high developer value for fast feedback
|
||||||
2. Implement Custom Reporters - important for CI/CD integration
|
2. Implement Custom Reporters - important for CI/CD integration
|
||||||
3. Fix known issues: tap.todo() and tap.skip.test() implementations
|
3. Implement performance benchmarking API
|
||||||
4. Implement performance benchmarking API
|
4. Add better error messages with suggestions
|
@@ -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 });
|
|
167
test/tapbundle/test.performance-metrics.ts
Normal file
167
test/tapbundle/test.performance-metrics.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
|
||||||
|
// Create tests with known, distinct timing patterns to verify metrics calculation
|
||||||
|
tap.test('metric test 1 - 10ms baseline', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 2 - 20ms double baseline', async (tools) => {
|
||||||
|
await tools.delayFor(20);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 3 - 30ms triple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(30);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 4 - 40ms quadruple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(40);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test 5 - 50ms quintuple baseline', async (tools) => {
|
||||||
|
await tools.delayFor(50);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that should be the slowest
|
||||||
|
tap.test('metric test slowest - 200ms intentionally slow', async (tools) => {
|
||||||
|
await tools.delayFor(200);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests to verify edge cases in average calculation
|
||||||
|
tap.test('metric test fast 1 - minimal work', async () => {
|
||||||
|
expect(1).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test fast 2 - minimal work', async () => {
|
||||||
|
expect(2).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('metric test fast 3 - minimal work', async () => {
|
||||||
|
expect(3).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to verify that failed tests still contribute to timing metrics
|
||||||
|
tap.test('metric test that fails - 60ms before failure', async (tools) => {
|
||||||
|
await tools.delayFor(60);
|
||||||
|
expect(true).toBeFalse(); // This will fail
|
||||||
|
});
|
||||||
|
|
||||||
|
// Describe block with timing to test aggregation
|
||||||
|
tap.describe('performance metrics in describe block', () => {
|
||||||
|
tap.test('described test 1 - 15ms', async (tools) => {
|
||||||
|
await tools.delayFor(15);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('described test 2 - 25ms', async (tools) => {
|
||||||
|
await tools.delayFor(25);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('described test 3 - 35ms', async (tools) => {
|
||||||
|
await tools.delayFor(35);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test timing with hooks
|
||||||
|
tap.describe('performance with hooks', () => {
|
||||||
|
let hookTime = 0;
|
||||||
|
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
// Hooks shouldn't count toward test time
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
hookTime += 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async () => {
|
||||||
|
// Hooks shouldn't count toward test time
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
hookTime += 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with hooks 1 - should only count test time', async (tools) => {
|
||||||
|
await tools.delayFor(30);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
// Test time should be ~30ms, not 50ms (including hooks)
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with hooks 2 - should only count test time', async (tools) => {
|
||||||
|
await tools.delayFor(40);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
// Test time should be ~40ms, not 60ms (including hooks)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel tests to verify timing is captured correctly
|
||||||
|
tap.describe('parallel timing verification', () => {
|
||||||
|
const startTimes: Map<string, number> = new Map();
|
||||||
|
const endTimes: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 1 - 80ms', async (tools) => {
|
||||||
|
startTimes.set('p1', Date.now());
|
||||||
|
await tools.delayFor(80);
|
||||||
|
endTimes.set('p1', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 2 - 90ms', async (tools) => {
|
||||||
|
startTimes.set('p2', Date.now());
|
||||||
|
await tools.delayFor(90);
|
||||||
|
endTimes.set('p2', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel metric 3 - 100ms', async (tools) => {
|
||||||
|
startTimes.set('p3', Date.now());
|
||||||
|
await tools.delayFor(100);
|
||||||
|
endTimes.set('p3', Date.now());
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify parallel execution', async () => {
|
||||||
|
// This test runs after parallel tests
|
||||||
|
// Verify they actually ran in parallel by checking overlapping times
|
||||||
|
if (startTimes.size === 3 && endTimes.size === 3) {
|
||||||
|
const p1Start = startTimes.get('p1')!;
|
||||||
|
const p2Start = startTimes.get('p2')!;
|
||||||
|
const p3Start = startTimes.get('p3')!;
|
||||||
|
const p1End = endTimes.get('p1')!;
|
||||||
|
const p2End = endTimes.get('p2')!;
|
||||||
|
const p3End = endTimes.get('p3')!;
|
||||||
|
|
||||||
|
// Start times should be very close (within 50ms)
|
||||||
|
expect(Math.abs(p1Start - p2Start)).toBeLessThan(50);
|
||||||
|
expect(Math.abs(p2Start - p3Start)).toBeLessThan(50);
|
||||||
|
|
||||||
|
// There should be overlap in execution
|
||||||
|
const p1Overlaps = p1Start < p2End && p1End > p2Start;
|
||||||
|
const p2Overlaps = p2Start < p3End && p2End > p3Start;
|
||||||
|
|
||||||
|
expect(p1Overlaps || p2Overlaps).toBeTrue();
|
||||||
|
} else {
|
||||||
|
// Skip verification if parallel tests didn't run yet
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to ensure average calculation handles mixed timing correctly
|
||||||
|
tap.test('final metrics test - 5ms minimal', async (tools) => {
|
||||||
|
await tools.delayFor(5);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n📊 Expected Performance Metrics Summary:');
|
||||||
|
console.log('- Tests include a mix of durations from <1ms to 200ms');
|
||||||
|
console.log('- Slowest test should be "metric test slowest" at ~200ms');
|
||||||
|
console.log('- Average should be calculated from individual test times');
|
||||||
|
console.log('- Failed test should still contribute its 60ms to timing');
|
||||||
|
console.log('- Parallel tests should show their individual times (80ms, 90ms, 100ms)');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
@@ -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 });
|
|
214
test/tapbundle/test.timing-edge-cases.ts
Normal file
214
test/tapbundle/test.timing-edge-cases.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
|
||||||
|
tap.test('ultra-fast test - should capture sub-millisecond timing', async () => {
|
||||||
|
// This test does almost nothing, should complete in < 1ms
|
||||||
|
const x = 1 + 1;
|
||||||
|
expect(x).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with exact 1ms delay', async (tools) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await tools.delayFor(1);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
// Should be at least 1ms but could be more due to event loop
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 10ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 100ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(100);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 250ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(250);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with 500ms delay', async (tools) => {
|
||||||
|
await tools.delayFor(500);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with variable processing time', async (tools) => {
|
||||||
|
// Simulate variable processing
|
||||||
|
const iterations = 1000000;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
sum += Math.sqrt(i);
|
||||||
|
}
|
||||||
|
expect(sum).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Add a small delay to ensure measurable time
|
||||||
|
await tools.delayFor(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with multiple async operations', async () => {
|
||||||
|
// Multiple promises in parallel
|
||||||
|
const results = await Promise.all([
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(1), 10)),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(2), 20)),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(3), 30))
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2, 3]);
|
||||||
|
// This should take at least 30ms (the longest delay)
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test with synchronous heavy computation', async () => {
|
||||||
|
// Heavy synchronous computation
|
||||||
|
const fibonacci = (n: number): number => {
|
||||||
|
if (n <= 1) return n;
|
||||||
|
return fibonacci(n - 1) + fibonacci(n - 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate fibonacci(30) - should take measurable time
|
||||||
|
const result = fibonacci(30);
|
||||||
|
expect(result).toEqual(832040);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with retry to see if timing accumulates correctly
|
||||||
|
tap.retry(2).test('test with retry - fails first then passes', async (tools) => {
|
||||||
|
// Get or initialize retry count
|
||||||
|
const retryCount = tools.context.get('retryCount') || 0;
|
||||||
|
tools.context.set('retryCount', retryCount + 1);
|
||||||
|
|
||||||
|
await tools.delayFor(50);
|
||||||
|
|
||||||
|
if (retryCount === 0) {
|
||||||
|
throw new Error('First attempt fails');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(retryCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test timeout handling
|
||||||
|
tap.timeout(100).test('test with timeout - should complete just in time', async (tools) => {
|
||||||
|
await tools.delayFor(80); // Just under the timeout
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip test - should show 0ms
|
||||||
|
tap.skip.test('skipped test - should report 0ms', async (tools) => {
|
||||||
|
await tools.delayFor(1000); // This won't execute
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todo test - should show 0ms
|
||||||
|
tap.todo.test('todo test - should report 0ms', async (tools) => {
|
||||||
|
await tools.delayFor(1000); // This won't execute
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with skip inside
|
||||||
|
tap.test('test that skips conditionally - should show time until skip', async (tools) => {
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
const shouldSkip = true;
|
||||||
|
if (shouldSkip) {
|
||||||
|
tools.skip('Skipping after 25ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This won't execute
|
||||||
|
await tools.delayFor(1000);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with very precise timing
|
||||||
|
tap.test('test with precise timing measurements', async (tools) => {
|
||||||
|
const measurements: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
await tools.delayFor(10);
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const durationMs = Number(end - start) / 1_000_000;
|
||||||
|
measurements.push(durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All measurements should be at least 10ms
|
||||||
|
measurements.forEach(m => {
|
||||||
|
expect(m).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// But not too much more (accounting for timer precision)
|
||||||
|
measurements.forEach(m => {
|
||||||
|
expect(m).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that intentionally has 0 actual work
|
||||||
|
tap.test('empty test - absolute minimum execution time', async () => {
|
||||||
|
// Literally nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with promise that resolves immediately
|
||||||
|
tap.test('test with immediate promise resolution', async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with microtask queue
|
||||||
|
tap.test('test with microtask queue processing', async () => {
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
await Promise.resolve().then(() => {
|
||||||
|
value = 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
}).then(() => {
|
||||||
|
value = 2;
|
||||||
|
return Promise.resolve();
|
||||||
|
}).then(() => {
|
||||||
|
value = 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(value).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test to verify timing accumulation in describe blocks
|
||||||
|
tap.describe('timing in describe blocks', () => {
|
||||||
|
let startTime: number;
|
||||||
|
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
startTime = Date.now();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('first test in describe', async (tools) => {
|
||||||
|
await tools.delayFor(10);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('second test in describe', async (tools) => {
|
||||||
|
await tools.delayFor(20);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel tests to see timing differences
|
||||||
|
tap.testParallel('parallel test 1 - 100ms', async (tools) => {
|
||||||
|
await tools.delayFor(100);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel test 2 - 50ms', async (tools) => {
|
||||||
|
await tools.delayFor(50);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.testParallel('parallel test 3 - 150ms', async (tools) => {
|
||||||
|
await tools.delayFor(150);
|
||||||
|
expect(true).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
204
test/tapbundle/test.timing-protocol.ts
Normal file
204
test/tapbundle/test.timing-protocol.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
import { ProtocolParser, ProtocolEmitter } from '../../ts_tapbundle_protocol/index.js';
|
||||||
|
|
||||||
|
// Test the protocol's ability to emit and parse timing metadata
|
||||||
|
tap.test('protocol should correctly emit timing metadata', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'test with timing',
|
||||||
|
metadata: {
|
||||||
|
time: 123
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
|
||||||
|
// Should have inline timing metadata
|
||||||
|
expect(lines.length).toEqual(1);
|
||||||
|
expect(lines[0]).toInclude('⟦TSTEST:time:123⟧');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should correctly parse timing metadata', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - test with timing ⟦TSTEST:time:456⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
expect(messages.length).toEqual(1);
|
||||||
|
expect(messages[0].type).toEqual('test');
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata).toBeDefined();
|
||||||
|
expect(content.metadata.time).toEqual(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle 0ms timing', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - ultra fast test ⟦TSTEST:time:0⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata.time).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle large timing values', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const line = 'ok 1 - slow test ⟦TSTEST:time:999999⟧';
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
const content = messages[0].content as any;
|
||||||
|
expect(content.metadata.time).toEqual(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle timing with other metadata', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'complex test',
|
||||||
|
metadata: {
|
||||||
|
time: 789,
|
||||||
|
file: 'test.ts',
|
||||||
|
tags: ['slow', 'integration']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
|
||||||
|
// Should use block metadata format for complex metadata
|
||||||
|
expect(lines.length).toBeGreaterThan(1);
|
||||||
|
expect(lines[1]).toInclude('META:');
|
||||||
|
expect(lines[1]).toInclude('"time":789');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should parse timing from block metadata', async () => {
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'ok 1 - complex test',
|
||||||
|
'⟦TSTEST:META:{"time":321,"file":"test.ts"}⟧'
|
||||||
|
];
|
||||||
|
|
||||||
|
let testResult: any;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
if (messages.length > 0 && messages[0].type === 'test') {
|
||||||
|
testResult = messages[0].content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testResult).toBeDefined();
|
||||||
|
expect(testResult.metadata).toBeUndefined(); // Metadata comes separately in block format
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('timing for skipped tests should be 0 or missing', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'skipped test',
|
||||||
|
directive: {
|
||||||
|
type: 'skip' as const,
|
||||||
|
reason: 'Not ready'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
time: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
expect(lines[0]).toInclude('# SKIP');
|
||||||
|
|
||||||
|
// If time is 0, it might be included or omitted
|
||||||
|
if (lines[0].includes('⟦TSTEST:')) {
|
||||||
|
expect(lines[0]).toInclude('time:0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle fractional milliseconds', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
// Even though we use integers, test that protocol handles them correctly
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'precise test',
|
||||||
|
metadata: {
|
||||||
|
time: 123 // Protocol uses integers for milliseconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
expect(lines[0]).toInclude('time:123');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('protocol should handle timing in retry scenarios', async () => {
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
const testResult = {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'retry test',
|
||||||
|
metadata: {
|
||||||
|
time: 200,
|
||||||
|
retry: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = emitter.emitTest(testResult);
|
||||||
|
// Should include both time and retry
|
||||||
|
expect(lines[0]).toMatch(/time:200.*retry:2|retry:2.*time:200/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test actual timing capture
|
||||||
|
tap.test('HrtMeasurement should capture accurate timing', async (tools) => {
|
||||||
|
// Import HrtMeasurement
|
||||||
|
const { HrtMeasurement } = await import('@push.rocks/smarttime');
|
||||||
|
|
||||||
|
const measurement = new HrtMeasurement();
|
||||||
|
measurement.start();
|
||||||
|
|
||||||
|
await tools.delayFor(50);
|
||||||
|
|
||||||
|
measurement.stop();
|
||||||
|
|
||||||
|
// Should be at least 50ms
|
||||||
|
expect(measurement.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
// But not too much more (allow for some overhead)
|
||||||
|
expect(measurement.milliSeconds).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multiple timing measurements should be independent', async (tools) => {
|
||||||
|
const { HrtMeasurement } = await import('@push.rocks/smarttime');
|
||||||
|
|
||||||
|
const measurement1 = new HrtMeasurement();
|
||||||
|
const measurement2 = new HrtMeasurement();
|
||||||
|
|
||||||
|
measurement1.start();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
measurement2.start();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
|
||||||
|
measurement1.stop();
|
||||||
|
await tools.delayFor(25);
|
||||||
|
measurement2.stop();
|
||||||
|
|
||||||
|
// measurement1 should be ~50ms (25ms + 25ms)
|
||||||
|
expect(measurement1.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(measurement1.milliSeconds).toBeLessThan(70);
|
||||||
|
|
||||||
|
// measurement2 should be ~50ms (25ms + 25ms)
|
||||||
|
expect(measurement2.milliSeconds).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(measurement2.milliSeconds).toBeLessThan(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
111
test/test.migration.node.ts
Normal file
111
test/test.migration.node.ts
Normal 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();
|
203
test/test.runtime.parser.node.ts
Normal file
203
test/test.runtime.parser.node.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('parseTestFilename - all keyword expands to all runtimes', async () => {
|
||||||
|
const parsed = parseTestFilename('test.all.ts');
|
||||||
|
expect(parsed.baseName).toEqual('test');
|
||||||
|
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
|
||||||
|
expect(parsed.modifiers).toEqual([]);
|
||||||
|
expect(parsed.extension).toEqual('ts');
|
||||||
|
expect(parsed.isLegacy).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('parseTestFilename - all keyword with nonci modifier', async () => {
|
||||||
|
const parsed = parseTestFilename('test.all.nonci.ts');
|
||||||
|
expect(parsed.baseName).toEqual('test');
|
||||||
|
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
|
||||||
|
expect(parsed.modifiers).toEqual(['nonci']);
|
||||||
|
expect(parsed.extension).toEqual('ts');
|
||||||
|
expect(parsed.isLegacy).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('parseTestFilename - all keyword with complex basename', async () => {
|
||||||
|
const parsed = parseTestFilename('test.some.feature.all.ts');
|
||||||
|
expect(parsed.baseName).toEqual('test.some.feature');
|
||||||
|
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
|
||||||
|
expect(parsed.modifiers).toEqual([]);
|
||||||
|
expect(parsed.extension).toEqual('ts');
|
||||||
|
expect(parsed.isLegacy).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('parseTestFilename - all keyword in chain expands to all runtimes', async () => {
|
||||||
|
const parsed = parseTestFilename('test.node+all.ts');
|
||||||
|
expect(parsed.baseName).toEqual('test');
|
||||||
|
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
|
||||||
|
expect(parsed.modifiers).toEqual([]);
|
||||||
|
expect(parsed.extension).toEqual('ts');
|
||||||
|
expect(parsed.isLegacy).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
17
test/watch-demo/test.demo.ts
Normal file
17
test/watch-demo/test.demo.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||||
|
|
||||||
|
// This test file demonstrates watch mode
|
||||||
|
// Try modifying this file while running: tstest test/watch-demo --watch
|
||||||
|
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
tap.test('demo test that changes', async () => {
|
||||||
|
expect(counter).toEqual(1);
|
||||||
|
console.log(`Test run at: ${new Date().toISOString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('another test', async () => {
|
||||||
|
expect('hello').toEqual('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tstest',
|
name: '@git.zone/tstest',
|
||||||
version: '2.1.0',
|
version: '2.5.0',
|
||||||
description: 'a test utility to run tests that match test/**/*.ts'
|
description: 'a test utility to run tests that match test/**/*.ts'
|
||||||
}
|
}
|
||||||
|
70
ts/index.ts
70
ts/index.ts
@@ -8,6 +8,40 @@ export enum TestExecutionMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const runCli = async () => {
|
export const runCli = async () => {
|
||||||
|
// Check if we're using global tstest in the tstest project itself
|
||||||
|
try {
|
||||||
|
const packageJsonPath = `${process.cwd()}/package.json`;
|
||||||
|
const fs = await import('fs');
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
if (packageJson.name === '@git.zone/tstest') {
|
||||||
|
// Check if we're running from a global installation
|
||||||
|
const execPath = process.argv[1];
|
||||||
|
// Debug: log the paths (uncomment for debugging)
|
||||||
|
// console.log('DEBUG: Checking global tstest usage...');
|
||||||
|
// console.log('execPath:', execPath);
|
||||||
|
// console.log('cwd:', process.cwd());
|
||||||
|
// console.log('process.argv:', process.argv);
|
||||||
|
|
||||||
|
// Check if this is running from global installation
|
||||||
|
const isLocalCli = execPath.includes(process.cwd());
|
||||||
|
const isGlobalPnpm = process.argv.some(arg => arg.includes('.pnpm') && !arg.includes(process.cwd()));
|
||||||
|
const isGlobalNpm = process.argv.some(arg => arg.includes('npm/node_modules') && !arg.includes(process.cwd()));
|
||||||
|
|
||||||
|
if (!isLocalCli && (isGlobalPnpm || isGlobalNpm || !execPath.includes('node_modules'))) {
|
||||||
|
console.error('\n⚠️ WARNING: You are using a globally installed tstest in the tstest project itself!');
|
||||||
|
console.error(' This means you are NOT testing your local changes.');
|
||||||
|
console.error(' Please use one of these commands instead:');
|
||||||
|
console.error(' • node cli.js <test-path>');
|
||||||
|
console.error(' • pnpm test <test-path>');
|
||||||
|
console.error(' • ./cli.js <test-path> (if executable)\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore any errors in this check
|
||||||
|
}
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const logOptions: LogOptions = {};
|
const logOptions: LogOptions = {};
|
||||||
@@ -16,12 +50,26 @@ export const runCli = async () => {
|
|||||||
let startFromFile: number | null = null;
|
let startFromFile: number | null = null;
|
||||||
let stopAtFile: number | null = null;
|
let stopAtFile: number | null = null;
|
||||||
let timeoutSeconds: number | null = null;
|
let timeoutSeconds: number | null = null;
|
||||||
|
let watchMode: boolean = false;
|
||||||
|
let watchIgnorePatterns: string[] = [];
|
||||||
|
|
||||||
// Parse options
|
// Parse options
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
|
|
||||||
switch (arg) {
|
switch (arg) {
|
||||||
|
case '--version':
|
||||||
|
// Get version from package.json
|
||||||
|
try {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const packagePath = new URL('../package.json', import.meta.url).pathname;
|
||||||
|
const packageData = JSON.parse(await fs.promises.readFile(packagePath, 'utf8'));
|
||||||
|
console.log(`tstest version ${packageData.version}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('tstest version unknown');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
case '--quiet':
|
case '--quiet':
|
||||||
case '-q':
|
case '-q':
|
||||||
logOptions.quiet = true;
|
logOptions.quiet = true;
|
||||||
@@ -84,6 +132,18 @@ export const runCli = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case '--watch':
|
||||||
|
case '-w':
|
||||||
|
watchMode = true;
|
||||||
|
break;
|
||||||
|
case '--watch-ignore':
|
||||||
|
if (i + 1 < args.length) {
|
||||||
|
watchIgnorePatterns = args[++i].split(',');
|
||||||
|
} else {
|
||||||
|
console.error('Error: --watch-ignore requires a comma-separated list of patterns');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (!arg.startsWith('-')) {
|
if (!arg.startsWith('-')) {
|
||||||
testPath = arg;
|
testPath = arg;
|
||||||
@@ -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('You must specify a test directory/file/pattern as argument. Please try again.');
|
||||||
console.error('\nUsage: tstest <path> [options]');
|
console.error('\nUsage: tstest <path> [options]');
|
||||||
console.error('\nOptions:');
|
console.error('\nOptions:');
|
||||||
|
console.error(' --version Show version information');
|
||||||
console.error(' --quiet, -q Minimal output');
|
console.error(' --quiet, -q Minimal output');
|
||||||
console.error(' --verbose, -v Verbose output');
|
console.error(' --verbose, -v Verbose output');
|
||||||
console.error(' --no-color Disable colored 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(' --startFrom <n> Start running from test file number n');
|
||||||
console.error(' --stopAt <n> Stop running at 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(' --timeout <s> Timeout test files after s seconds');
|
||||||
|
console.error(' --watch, -w Watch for file changes and re-run tests');
|
||||||
|
console.error(' --watch-ignore Patterns to ignore in watch mode (comma-separated)');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +188,12 @@ export const runCli = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile, timeoutSeconds);
|
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
|
// Execute CLI when this file is run directly
|
||||||
|
316
ts/tstest.classes.migration.ts
Normal file
316
ts/tstest.classes.migration.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
245
ts/tstest.classes.runtime.adapter.ts
Normal file
245
ts/tstest.classes.runtime.adapter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
219
ts/tstest.classes.runtime.bun.ts
Normal file
219
ts/tstest.classes.runtime.bun.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
293
ts/tstest.classes.runtime.chromium.ts
Normal file
293
ts/tstest.classes.runtime.chromium.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
262
ts/tstest.classes.runtime.deno.ts
Normal file
262
ts/tstest.classes.runtime.deno.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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',
|
||||||
|
'--allow-sys', // Allow system info access
|
||||||
|
'--allow-import', // Allow npm/node imports
|
||||||
|
'--node-modules-dir', // Enable Node.js compatibility mode
|
||||||
|
'--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',
|
||||||
|
'--allow-sys',
|
||||||
|
'--allow-import',
|
||||||
|
'--node-modules-dir',
|
||||||
|
'--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;
|
||||||
|
}
|
||||||
|
}
|
222
ts/tstest.classes.runtime.node.ts
Normal file
222
ts/tstest.classes.runtime.node.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
230
ts/tstest.classes.runtime.parser.ts
Normal file
230
ts/tstest.classes.runtime.parser.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* 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.all.ts (runs on all runtimes)
|
||||||
|
* - 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']);
|
||||||
|
const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun'];
|
||||||
|
|
||||||
|
// 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[] = [];
|
||||||
|
let hasAllKeyword = false;
|
||||||
|
|
||||||
|
for (const candidate of runtimeCandidates) {
|
||||||
|
if (candidate === 'all') {
|
||||||
|
hasAllKeyword = true;
|
||||||
|
} else 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 'all' keyword is present, expand to all runtimes
|
||||||
|
if (hasAllKeyword) {
|
||||||
|
runtimes = [...ALL_RUNTIMES];
|
||||||
|
runtimeTokenIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidRuntimes.length > 0) {
|
||||||
|
if (strictUnknownRuntime) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
|
||||||
|
`Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}, all`
|
||||||
|
);
|
||||||
|
} 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 the 'all' keyword (expands to all runtimes)
|
||||||
|
if (token === 'all') {
|
||||||
|
runtimes = [...ALL_RUNTIMES];
|
||||||
|
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('.');
|
||||||
|
}
|
@@ -22,6 +22,7 @@ export class TapParser {
|
|||||||
private logger: TsTestLogger;
|
private logger: TsTestLogger;
|
||||||
private protocolParser: ProtocolParser;
|
private protocolParser: ProtocolParser;
|
||||||
private protocolVersion: string | null = null;
|
private protocolVersion: string | null = null;
|
||||||
|
private startTime: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the constructor for TapParser
|
* the constructor for TapParser
|
||||||
@@ -29,6 +30,7 @@ export class TapParser {
|
|||||||
constructor(public fileName: string, logger?: TsTestLogger) {
|
constructor(public fileName: string, logger?: TsTestLogger) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.protocolParser = new ProtocolParser();
|
this.protocolParser = new ProtocolParser();
|
||||||
|
this.startTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -480,6 +482,7 @@ export class TapParser {
|
|||||||
|
|
||||||
public async evaluateFinalResult() {
|
public async evaluateFinalResult() {
|
||||||
this.receivedTests = this.testStore.length;
|
this.receivedTests = this.testStore.length;
|
||||||
|
const duration = Date.now() - this.startTime;
|
||||||
|
|
||||||
// check wether all tests ran
|
// check wether all tests ran
|
||||||
if (this.expectedTests === this.receivedTests) {
|
if (this.expectedTests === this.receivedTests) {
|
||||||
@@ -494,23 +497,23 @@ export class TapParser {
|
|||||||
if (!this.expectedTests && this.receivedTests === 0) {
|
if (!this.expectedTests && this.receivedTests === 0) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.error('No tests were defined. Therefore the testfile failed!');
|
this.logger.error('No tests were defined. Therefore the testfile failed!');
|
||||||
this.logger.testFileEnd(0, 1, 0); // Count as 1 failure
|
this.logger.testFileEnd(0, 1, duration); // Count as 1 failure
|
||||||
}
|
}
|
||||||
} else if (this.expectedTests !== this.receivedTests) {
|
} else if (this.expectedTests !== this.receivedTests) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
|
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
|
||||||
const errorCount = this.getErrorTests().length || 1; // At least 1 error
|
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) {
|
} else if (this.getErrorTests().length === 0) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.tapOutput('All tests are successfull!!!');
|
this.logger.tapOutput('All tests are successfull!!!');
|
||||||
this.logger.testFileEnd(this.receivedTests, 0, 0);
|
this.logger.testFileEnd(this.receivedTests, 0, duration);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js';
|
|||||||
import { TsTestLogger } from './tstest.logging.js';
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
import type { LogOptions } 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 {
|
export class TsTest {
|
||||||
public testDir: TestDirectory;
|
public testDir: TestDirectory;
|
||||||
public executionMode: TestExecutionMode;
|
public executionMode: TestExecutionMode;
|
||||||
@@ -18,7 +26,6 @@ export class TsTest {
|
|||||||
public startFromFile: number | null;
|
public startFromFile: number | null;
|
||||||
public stopAtFile: number | null;
|
public stopAtFile: number | null;
|
||||||
public timeoutSeconds: number | null;
|
public timeoutSeconds: number | null;
|
||||||
private timeoutWarningTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
public smartshellInstance = new plugins.smartshell.Smartshell({
|
public smartshellInstance = new plugins.smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
@@ -29,6 +36,8 @@ export class TsTest {
|
|||||||
|
|
||||||
public tsbundleInstance = new plugins.tsbundle.TsBundle();
|
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) {
|
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
|
||||||
this.executionMode = executionModeArg;
|
this.executionMode = executionModeArg;
|
||||||
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
||||||
@@ -37,6 +46,20 @@ export class TsTest {
|
|||||||
this.startFromFile = startFromFile;
|
this.startFromFile = startFromFile;
|
||||||
this.stopAtFile = stopAtFile;
|
this.stopAtFile = stopAtFile;
|
||||||
this.timeoutSeconds = timeoutSeconds;
|
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() {
|
async run() {
|
||||||
@@ -45,15 +68,6 @@ export class TsTest {
|
|||||||
await this.movePreviousLogFiles();
|
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 testGroups = await this.testDir.getTestFileGroups();
|
||||||
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
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();
|
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) {
|
private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||||
// Check if this file should be skipped based on range
|
// Check if this file should be skipped based on range
|
||||||
if (this.startFromFile !== null && fileIndex < this.startFromFile) {
|
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) {
|
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||||
switch (true) {
|
// Parse the filename to determine runtimes and modifiers
|
||||||
case process.env.CI && fileNameArg.includes('.nonci.'):
|
const fileName = plugins.path.basename(fileNameArg);
|
||||||
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
|
const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
|
||||||
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);
|
|
||||||
this.logger.sectionEnd();
|
|
||||||
|
|
||||||
this.logger.sectionStart('Part 2: Node');
|
// Check for nonci modifier in CI environment
|
||||||
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
|
if (process.env.CI && parsed.modifiers.includes('nonci')) {
|
||||||
tapCombinator.addTapParser(tapParserBothNode);
|
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.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);
|
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
|
// Handle timeout if specified
|
||||||
if (this.timeoutSeconds !== null) {
|
if (this.timeoutSeconds !== null) {
|
||||||
const timeoutMs = this.timeoutSeconds * 1000;
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
@@ -222,6 +335,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
// Clear timeout if test completed successfully
|
// Clear timeout if test completed successfully
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
// Handle timeout error
|
// Handle timeout error
|
||||||
tapParser.handleTimeout(this.timeoutSeconds);
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
// Ensure entire process tree is killed if still running
|
// Ensure entire process tree is killed if still running
|
||||||
@@ -236,9 +353,39 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
return tapParser;
|
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> {
|
public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> {
|
||||||
this.logger.testFileStart(fileNameArg, 'chromium', index, total);
|
this.logger.testFileStart(fileNameArg, 'chromium', index, total);
|
||||||
|
|
||||||
@@ -253,10 +400,13 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
bundler: 'esbuild',
|
bundler: 'esbuild',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Find free ports for HTTP and WebSocket
|
||||||
|
const { httpPort, wsPort } = await this.findFreePorts();
|
||||||
|
|
||||||
// lets create a server
|
// lets create a server
|
||||||
const server = new plugins.typedserver.servertools.Server({
|
const server = new plugins.typedserver.servertools.Server({
|
||||||
cors: true,
|
cors: true,
|
||||||
port: 3007,
|
port: httpPort,
|
||||||
});
|
});
|
||||||
server.addRoute(
|
server.addRoute(
|
||||||
'/test',
|
'/test',
|
||||||
@@ -267,6 +417,7 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
<head>
|
<head>
|
||||||
<script>
|
<script>
|
||||||
globalThis.testdom = true;
|
globalThis.testdom = true;
|
||||||
|
globalThis.wsPort = ${wsPort};
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body></body>
|
<body></body>
|
||||||
@@ -275,12 +426,12 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
res.end();
|
res.end();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
server.addRoute('*', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
|
server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
||||||
// lets handle realtime comms
|
// lets handle realtime comms
|
||||||
const tapParser = new TapParser(fileNameArg + ':chrome', this.logger);
|
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) => {
|
wss.on('connection', (ws) => {
|
||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
const messageStr = message.toString();
|
const messageStr = message.toString();
|
||||||
@@ -297,10 +448,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
await this.smartbrowserInstance.start();
|
await this.smartbrowserInstance.start();
|
||||||
|
|
||||||
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
|
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
|
||||||
`http://localhost:3007/test?bundleName=${bundleFileName}`,
|
`http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
|
||||||
async () => {
|
async () => {
|
||||||
// lets enable real time comms
|
// 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));
|
await new Promise((resolve) => (ws.onopen = resolve));
|
||||||
|
|
||||||
// Ensure this function is declared with 'async'
|
// 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
|
// Handle timeout if specified
|
||||||
if (this.timeoutSeconds !== null) {
|
if (this.timeoutSeconds !== null) {
|
||||||
const timeoutMs = this.timeoutSeconds * 1000;
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
@@ -373,6 +537,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
// Clear timeout if test completed successfully
|
// Clear timeout if test completed successfully
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
// Handle timeout error
|
// Handle timeout error
|
||||||
tapParser.handleTimeout(this.timeoutSeconds);
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
}
|
}
|
||||||
@@ -380,6 +548,11 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
await evaluatePromise;
|
await evaluatePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
// Always clean up resources, even on timeout
|
// Always clean up resources, even on timeout
|
||||||
try {
|
try {
|
||||||
await this.smartbrowserInstance.stop();
|
await this.smartbrowserInstance.stop();
|
||||||
@@ -417,10 +590,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete 00err and 00diff directories if they exist
|
// Delete 00err and 00diff directories if they exist
|
||||||
if (await plugins.smartfile.fs.isDirectory(errDir)) {
|
if (plugins.smartfile.fs.isDirectorySync(errDir)) {
|
||||||
plugins.smartfile.fs.removeSync(errDir);
|
plugins.smartfile.fs.removeSync(errDir);
|
||||||
}
|
}
|
||||||
if (await plugins.smartfile.fs.isDirectory(diffDir)) {
|
if (plugins.smartfile.fs.isDirectorySync(diffDir)) {
|
||||||
plugins.smartfile.fs.removeSync(diffDir);
|
plugins.smartfile.fs.removeSync(diffDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -242,9 +242,13 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
if (!this.options.quiet) {
|
if (!this.options.quiet) {
|
||||||
const total = passed + failed;
|
const total = passed + failed;
|
||||||
const status = failed === 0 ? 'PASSED' : 'FAILED';
|
const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
|
||||||
const color = failed === 0 ? 'green' : 'red';
|
|
||||||
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
|
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
|
// If using --logfile, handle error copy and diff detection
|
||||||
@@ -390,7 +394,13 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
if (this.options.quiet) {
|
if (this.options.quiet) {
|
||||||
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
|
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
|
||||||
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +414,8 @@ export class TsTestLogger {
|
|||||||
if (summary.totalSkipped > 0) {
|
if (summary.totalSkipped > 0) {
|
||||||
this.log(this.format(`│ Skipped: ${summary.totalSkipped.toString().padStart(14)} │`, 'yellow'));
|
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'));
|
this.log(this.format('└────────────────────────────────┘', 'dim'));
|
||||||
|
|
||||||
// File results
|
// File results
|
||||||
@@ -425,15 +436,23 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
// Performance metrics
|
// Performance metrics
|
||||||
if (this.options.verbose) {
|
if (this.options.verbose) {
|
||||||
const avgDuration = Math.round(totalDuration / summary.totalTests);
|
// Calculate metrics based on actual test durations
|
||||||
const slowestTest = this.fileResults
|
const allTests = this.fileResults.flatMap(r => r.tests);
|
||||||
.flatMap(r => r.tests)
|
const testDurations = allTests.map(t => t.duration);
|
||||||
.sort((a, b) => b.duration - a.duration)[0];
|
const sumOfTestDurations = testDurations.reduce((sum, d) => sum + d, 0);
|
||||||
|
const avgTestDuration = allTests.length > 0 ? Math.round(sumOfTestDurations / allTests.length) : 0;
|
||||||
|
|
||||||
|
// Find slowest test (exclude 0ms durations unless all are 0)
|
||||||
|
const nonZeroDurations = allTests.filter(t => t.duration > 0);
|
||||||
|
const testsToSort = nonZeroDurations.length > 0 ? nonZeroDurations : allTests;
|
||||||
|
const slowestTest = testsToSort.sort((a, b) => b.duration - a.duration)[0];
|
||||||
|
|
||||||
this.log(this.format('\n⏱️ Performance Metrics:', 'cyan'));
|
this.log(this.format('\n⏱️ Performance Metrics:', 'cyan'));
|
||||||
this.log(this.format(` Average per test: ${avgDuration}ms`, 'white'));
|
this.log(this.format(` Average per test: ${avgTestDuration}ms`, 'white'));
|
||||||
if (slowestTest) {
|
if (slowestTest && slowestTest.duration > 0) {
|
||||||
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'yellow'));
|
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'orange'));
|
||||||
|
} else if (allTests.length > 0) {
|
||||||
|
this.log(this.format(` All tests completed in <1ms`, 'dim'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,4 +539,47 @@ export class TsTestLogger {
|
|||||||
|
|
||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch mode methods
|
||||||
|
watchModeStart() {
|
||||||
|
if (this.options.json) {
|
||||||
|
this.logJson({ event: 'watchModeStart' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(this.format('\n👀 Watch Mode', 'cyan'));
|
||||||
|
this.log(this.format(' Running tests in watch mode...', 'dim'));
|
||||||
|
this.log(this.format(' Press Ctrl+C to exit\n', 'dim'));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchModeWaiting() {
|
||||||
|
if (this.options.json) {
|
||||||
|
this.logJson({ event: 'watchModeWaiting' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(this.format('\n Waiting for file changes...', 'dim'));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchModeRerun(changedFiles: string[]) {
|
||||||
|
if (this.options.json) {
|
||||||
|
this.logJson({ event: 'watchModeRerun', changedFiles });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(this.format('\n🔄 File changes detected:', 'cyan'));
|
||||||
|
changedFiles.forEach(file => {
|
||||||
|
this.log(this.format(` • ${file}`, 'yellow'));
|
||||||
|
});
|
||||||
|
this.log(this.format('\n Re-running tests...\n', 'dim'));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchModeStop() {
|
||||||
|
if (this.options.json) {
|
||||||
|
this.logJson({ event: 'watchModeStop' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(this.format('\n\n👋 Stopping watch mode...', 'cyan'));
|
||||||
|
}
|
||||||
}
|
}
|
@@ -13,9 +13,11 @@ export {
|
|||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as consolecolor from '@push.rocks/consolecolor';
|
import * as consolecolor from '@push.rocks/consolecolor';
|
||||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||||
|
import * as smartchok from '@push.rocks/smartchok';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartshell from '@push.rocks/smartshell';
|
import * as smartshell from '@push.rocks/smartshell';
|
||||||
import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
||||||
@@ -23,15 +25,17 @@ import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
|||||||
export {
|
export {
|
||||||
consolecolor,
|
consolecolor,
|
||||||
smartbrowser,
|
smartbrowser,
|
||||||
|
smartchok,
|
||||||
smartdelay,
|
smartdelay,
|
||||||
smartfile,
|
smartfile,
|
||||||
smartlog,
|
smartlog,
|
||||||
|
smartnetwork,
|
||||||
smartpromise,
|
smartpromise,
|
||||||
smartshell,
|
smartshell,
|
||||||
tapbundle,
|
tapbundle,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @gitzone scope
|
// @git.zone scope
|
||||||
import * as tsbundle from '@git.zone/tsbundle';
|
import * as tsbundle from '@git.zone/tsbundle';
|
||||||
|
|
||||||
export { tsbundle };
|
export { tsbundle };
|
||||||
|
389
ts_tapbundle/readme.md
Normal file
389
ts_tapbundle/readme.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# @git.zone/tstest/tapbundle
|
||||||
|
|
||||||
|
> 🧪 Core TAP testing framework with enhanced assertions and lifecycle hooks
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tapbundle is typically included as part of @git.zone/tstest
|
||||||
|
pnpm install --save-dev @git.zone/tstest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`@git.zone/tstest/tapbundle` is the core testing framework module that provides the TAP (Test Anything Protocol) implementation for tstest. It offers a comprehensive API for writing and organizing tests with support for lifecycle hooks, test suites, enhanced assertions with diff generation, and flexible test configuration.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- 🎯 **TAP Protocol Compliant** - Full TAP version 13 support
|
||||||
|
- 🔍 **Enhanced Assertions** - Built on smartexpect with automatic diff generation
|
||||||
|
- 🏗️ **Test Suites** - Organize tests with `describe()` blocks
|
||||||
|
- 🔄 **Lifecycle Hooks** - beforeEach/afterEach at suite and global levels
|
||||||
|
- 🏷️ **Test Tagging** - Filter tests by tags for selective execution
|
||||||
|
- ⚡ **Parallel Testing** - Run tests concurrently with `testParallel()`
|
||||||
|
- 🔁 **Automatic Retries** - Configure retry logic for flaky tests
|
||||||
|
- ⏱️ **Timeout Control** - Set timeouts at global, file, or test level
|
||||||
|
- 🎨 **Fluent API** - Chain test configurations with builder pattern
|
||||||
|
- 📊 **Protocol Events** - Real-time test execution events
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Simple Test File
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('should add numbers correctly', async () => {
|
||||||
|
const result = 2 + 2;
|
||||||
|
expect(result).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Test Suites
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.describe('Calculator', () => {
|
||||||
|
tap.beforeEach(async (tapTools) => {
|
||||||
|
// Setup before each test in this suite
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add', async () => {
|
||||||
|
expect(2 + 2).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should subtract', async () => {
|
||||||
|
expect(5 - 3).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async (tapTools) => {
|
||||||
|
// Cleanup after each test in this suite
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Main Test Methods
|
||||||
|
|
||||||
|
#### `tap.test(description, testFunction)`
|
||||||
|
|
||||||
|
Define a standard test that runs sequentially.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('should validate user input', async () => {
|
||||||
|
// test code
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tap.testParallel(description, testFunction)`
|
||||||
|
|
||||||
|
Define a test that runs in parallel with other parallel tests.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.testParallel('should fetch user data', async () => {
|
||||||
|
// test code
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tap.describe(description, suiteFunction)`
|
||||||
|
|
||||||
|
Create a test suite to group related tests.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.describe('User Authentication', () => {
|
||||||
|
tap.test('should login', async () => { });
|
||||||
|
tap.test('should logout', async () => { });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Modes
|
||||||
|
|
||||||
|
#### Skip Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.skip.test('not ready yet', async () => {
|
||||||
|
// This test will be skipped
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Only Mode
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.only.test('focus on this test', async () => {
|
||||||
|
// Only tests marked with 'only' will run
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Todo Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.todo.test('implement feature X');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fluent Test Builder
|
||||||
|
|
||||||
|
Chain test configurations for expressive test definitions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap
|
||||||
|
.tags('integration', 'database')
|
||||||
|
.priority('high')
|
||||||
|
.retry(3)
|
||||||
|
.timeout(5000)
|
||||||
|
.test('should handle database connection', async () => {
|
||||||
|
// test with configured settings
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Hooks
|
||||||
|
|
||||||
|
#### Suite-Level Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.describe('Database Tests', () => {
|
||||||
|
tap.beforeEach(async (tapTools) => {
|
||||||
|
// Runs before each test in this suite
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async (tapTools) => {
|
||||||
|
// Runs after each test in this suite
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test 1', async () => { });
|
||||||
|
tap.test('test 2', async () => { });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Global Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.settings({
|
||||||
|
beforeAll: async () => {
|
||||||
|
// Runs once before all tests
|
||||||
|
},
|
||||||
|
afterAll: async () => {
|
||||||
|
// Runs once after all tests
|
||||||
|
},
|
||||||
|
beforeEach: async (testName) => {
|
||||||
|
// Runs before every test
|
||||||
|
},
|
||||||
|
afterEach: async (testName, passed) => {
|
||||||
|
// Runs after every test
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Settings
|
||||||
|
|
||||||
|
Configure test behavior at the file level:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.settings({
|
||||||
|
timeout: 10000, // Default timeout for all tests
|
||||||
|
retries: 2, // Retry failed tests
|
||||||
|
retryDelay: 1000, // Delay between retries
|
||||||
|
bail: false, // Stop on first failure
|
||||||
|
suppressConsole: false, // Hide console output
|
||||||
|
verboseErrors: true, // Show full stack traces
|
||||||
|
showTestDuration: true, // Display test durations
|
||||||
|
maxConcurrency: 4, // Max parallel tests
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Assertions
|
||||||
|
|
||||||
|
The `expect` function is an enhanced wrapper around [@push.rocks/smartexpect](https://code.foss.global/push.rocks/smartexpect) that automatically generates diffs for failed assertions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('should compare objects', async () => {
|
||||||
|
const actual = { name: 'John', age: 30 };
|
||||||
|
const expected = { name: 'John', age: 31 };
|
||||||
|
|
||||||
|
// Will show a detailed diff of the differences
|
||||||
|
expect(actual).toEqual(expected);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Assertions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Equality
|
||||||
|
expect(value).toEqual(expected);
|
||||||
|
expect(value).toBe(expected);
|
||||||
|
|
||||||
|
// Truthiness
|
||||||
|
expect(value).toBeTruthy();
|
||||||
|
expect(value).toBeFalsy();
|
||||||
|
|
||||||
|
// Type checks
|
||||||
|
expect(value).toBeType('string');
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect(string).toMatch(/pattern/);
|
||||||
|
expect(string).toContain('substring');
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
expect(array).toContain(item);
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
expect(fn).toThrow();
|
||||||
|
expect(fn).toThrow('error message');
|
||||||
|
|
||||||
|
// Async
|
||||||
|
await expect(promise).toResolve();
|
||||||
|
await expect(promise).toReject();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Tagging and Filtering
|
||||||
|
|
||||||
|
Tag tests for selective execution:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Define tests with tags
|
||||||
|
tap.tags('integration', 'slow').test('complex test', async () => {
|
||||||
|
// test code
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.tags('unit').test('fast test', async () => {
|
||||||
|
// test code
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter tests by setting the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TSTEST_FILTER_TAGS=unit tstest test/mytest.node.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### TapTools
|
||||||
|
|
||||||
|
Each test receives a `tapTools` instance with utilities:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('should have utilities', async (tapTools) => {
|
||||||
|
// Mark test as skipped
|
||||||
|
tapTools.markAsSkipped('reason');
|
||||||
|
|
||||||
|
// Mark as todo
|
||||||
|
tapTools.todo('not implemented');
|
||||||
|
|
||||||
|
// Configure retries
|
||||||
|
tapTools.retry(3);
|
||||||
|
|
||||||
|
// Log test output
|
||||||
|
tapTools.log('debug message');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Pre-Tasks
|
||||||
|
|
||||||
|
Run setup tasks before any tests execute:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.preTask('setup database', async () => {
|
||||||
|
// Runs before any tests
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('first test', async () => {
|
||||||
|
// Database is ready
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Priority
|
||||||
|
|
||||||
|
Organize tests by priority level:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.priority('high').test('critical test', async () => { });
|
||||||
|
tap.priority('medium').test('normal test', async () => { });
|
||||||
|
tap.priority('low').test('optional test', async () => { });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nested Suites
|
||||||
|
|
||||||
|
Create deeply nested test organization:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.describe('API', () => {
|
||||||
|
tap.describe('Users', () => {
|
||||||
|
tap.describe('GET /users', () => {
|
||||||
|
tap.test('should return all users', async () => { });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Events
|
||||||
|
|
||||||
|
Access real-time test events for custom tooling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { setProtocolEmitter } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
// Get access to protocol emitter for custom event handling
|
||||||
|
// Events: test:started, test:completed, assertion:failed, suite:started, suite:completed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always export `tap.start()`** at the end of test files:
|
||||||
|
```typescript
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use descriptive test names** that explain what is being tested:
|
||||||
|
```typescript
|
||||||
|
tap.test('should return 404 when user does not exist', async () => { });
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Group related tests** with `describe()` blocks:
|
||||||
|
```typescript
|
||||||
|
tap.describe('User validation', () => {
|
||||||
|
// All user validation tests
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Leverage lifecycle hooks** to reduce duplication:
|
||||||
|
```typescript
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
// Common setup
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Tag tests appropriately** for flexible test execution:
|
||||||
|
```typescript
|
||||||
|
tap.tags('integration', 'database').test('...', async () => { });
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Support
|
||||||
|
|
||||||
|
tapbundle is written in TypeScript and provides full type definitions. The `Tap` class accepts a generic type for shared context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MyTestContext {
|
||||||
|
db: DatabaseConnection;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tap = new Tap<MyTestContext>();
|
||||||
|
|
||||||
|
tap.test('should use context', async (tapTools) => {
|
||||||
|
// tapTools is typed with MyTestContext
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
This project is licensed under MIT.
|
||||||
|
|
||||||
|
© 2025 Task Venture Capital GmbH. All rights reserved.
|
@@ -11,9 +11,9 @@ import { HrtMeasurement } from '@push.rocks/smarttime';
|
|||||||
// interfaces
|
// interfaces
|
||||||
export type TTestStatus = 'success' | 'error' | 'pending' | 'errorAfterSuccess' | 'timeout' | 'skipped';
|
export type TTestStatus = 'success' | 'error' | 'pending' | 'errorAfterSuccess' | 'timeout' | 'skipped';
|
||||||
|
|
||||||
export interface ITestFunction<T> {
|
export type ITestFunction<T> =
|
||||||
(tapTools?: TapTools): Promise<T>;
|
| ((tapTools: TapTools) => Promise<T>)
|
||||||
}
|
| (() => Promise<T>);
|
||||||
|
|
||||||
export class TapTest<T = unknown> {
|
export class TapTest<T = unknown> {
|
||||||
public description: string;
|
public description: string;
|
||||||
@@ -173,7 +173,9 @@ export class TapTest<T = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the test function with potential timeout
|
// 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
|
const testReturnValue = timeoutPromise
|
||||||
? await Promise.race([testPromise, timeoutPromise])
|
? await Promise.race([testPromise, timeoutPromise])
|
||||||
: await testPromise;
|
: await testPromise;
|
||||||
|
@@ -9,9 +9,12 @@ export class TestFileProvider {
|
|||||||
public async getDockerAlpineImageAsLocalTarball(): Promise<string> {
|
public async getDockerAlpineImageAsLocalTarball(): Promise<string> {
|
||||||
const filePath = plugins.path.join(paths.testFilesDir, 'alpine.tar')
|
const filePath = plugins.path.join(paths.testFilesDir, 'alpine.tar')
|
||||||
// fetch the docker alpine image
|
// 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.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;
|
return filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
367
ts_tapbundle_node/readme.md
Normal file
367
ts_tapbundle_node/readme.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# @git.zone/tstest/tapbundle_node
|
||||||
|
|
||||||
|
> 🔧 Node.js-specific testing utilities for enhanced test capabilities
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tapbundle_node is included as part of @git.zone/tstest
|
||||||
|
pnpm install --save-dev @git.zone/tstest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`@git.zone/tstest/tapbundle_node` provides Node.js-specific utilities for testing. These tools are only available when running tests in Node.js runtime and provide functionality for working with environment variables, shell commands, test databases, storage systems, and HTTPS certificates.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- 🔐 **Environment Variables** - On-demand environment variable loading with qenv
|
||||||
|
- 💻 **Shell Commands** - Execute bash commands during tests
|
||||||
|
- 🔒 **HTTPS Certificates** - Generate self-signed certificates for testing
|
||||||
|
- 🗄️ **MongoDB Testing** - Create ephemeral MongoDB instances
|
||||||
|
- 📦 **S3 Storage Testing** - Create local S3-compatible storage for tests
|
||||||
|
- 📁 **Test File Management** - Download and manage test assets
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
import { tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('should use node-specific tools', async () => {
|
||||||
|
// Use Node.js-specific utilities
|
||||||
|
const result = await tapNodeTools.runCommand('echo "hello"');
|
||||||
|
console.log(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### tapNodeTools
|
||||||
|
|
||||||
|
The main singleton instance providing all Node.js-specific utilities.
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
##### `getQenv()`
|
||||||
|
|
||||||
|
Get the qenv instance for managing environment variables from `.nogit/` directory.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const qenv = await tapNodeTools.getQenv();
|
||||||
|
// qenv will load from .env files in .nogit/ directory
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `getEnvVarOnDemand(envVarName)`
|
||||||
|
|
||||||
|
Request an environment variable. If not available, qenv will prompt for it and store it securely.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('should get API key', async () => {
|
||||||
|
const apiKey = await tapNodeTools.getEnvVarOnDemand('GITHUB_API_KEY');
|
||||||
|
// If GITHUB_API_KEY is not set, qenv will prompt for it
|
||||||
|
// The value is stored in .nogit/.env for future use
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- API keys for integration tests
|
||||||
|
- Database credentials
|
||||||
|
- Service endpoints
|
||||||
|
- Any sensitive configuration needed for testing
|
||||||
|
|
||||||
|
#### Shell Commands
|
||||||
|
|
||||||
|
##### `runCommand(command)`
|
||||||
|
|
||||||
|
Execute a bash command and return the result.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('should execute shell commands', async () => {
|
||||||
|
const result = await tapNodeTools.runCommand('ls -la');
|
||||||
|
console.log(result.stdout);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Setup test environment
|
||||||
|
- Execute CLI tools
|
||||||
|
- File system operations
|
||||||
|
- Process management
|
||||||
|
|
||||||
|
#### HTTPS Certificates
|
||||||
|
|
||||||
|
##### `createHttpsCert(commonName?, allowSelfSigned?)`
|
||||||
|
|
||||||
|
Generate a self-signed HTTPS certificate for testing secure connections.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('should create HTTPS server', async () => {
|
||||||
|
const { key, cert } = await tapNodeTools.createHttpsCert('localhost', true);
|
||||||
|
|
||||||
|
// Use with Node.js https module
|
||||||
|
const server = https.createServer({ key, cert }, (req, res) => {
|
||||||
|
res.end('Hello Secure World');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(3000);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `commonName` (optional): Certificate common name, default: 'localhost'
|
||||||
|
- `allowSelfSigned` (optional): Allow self-signed certificates by setting `NODE_TLS_REJECT_UNAUTHORIZED=0`, default: true
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- `key`: PEM-encoded private key
|
||||||
|
- `cert`: PEM-encoded certificate
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Testing HTTPS servers
|
||||||
|
- Testing secure WebSocket connections
|
||||||
|
- Testing certificate validation logic
|
||||||
|
- Mocking secure external services
|
||||||
|
|
||||||
|
#### Database Testing
|
||||||
|
|
||||||
|
##### `createSmartmongo()`
|
||||||
|
|
||||||
|
Create an ephemeral MongoDB instance for testing. Automatically started and ready to use.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
|
||||||
|
tap.test('should use MongoDB', async () => {
|
||||||
|
const mongoInstance = await tapNodeTools.createSmartmongo();
|
||||||
|
|
||||||
|
// Use the MongoDB instance
|
||||||
|
const db = await mongoInstance.getDatabase('testdb');
|
||||||
|
const collection = await db.getCollection('users');
|
||||||
|
|
||||||
|
await collection.insertOne({ name: 'Alice', age: 30 });
|
||||||
|
const user = await collection.findOne({ name: 'Alice' });
|
||||||
|
|
||||||
|
expect(user.age).toEqual(30);
|
||||||
|
|
||||||
|
// Cleanup (optional - instance will be cleaned up automatically)
|
||||||
|
await mongoInstance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Ephemeral instance (starts fresh)
|
||||||
|
- Automatic cleanup
|
||||||
|
- Full MongoDB API via [@push.rocks/smartmongo](https://code.foss.global/push.rocks/smartmongo)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Testing database operations
|
||||||
|
- Integration tests with MongoDB
|
||||||
|
- Testing data models
|
||||||
|
- Schema validation tests
|
||||||
|
|
||||||
|
#### Storage Testing
|
||||||
|
|
||||||
|
##### `createSmarts3()`
|
||||||
|
|
||||||
|
Create a local S3-compatible storage instance for testing object storage operations.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
|
||||||
|
tap.test('should use S3 storage', async () => {
|
||||||
|
const s3Instance = await tapNodeTools.createSmarts3();
|
||||||
|
|
||||||
|
// Use the S3 instance (MinIO-compatible API)
|
||||||
|
const bucket = await s3Instance.createBucket('test-bucket');
|
||||||
|
await bucket.putObject('file.txt', Buffer.from('Hello S3'));
|
||||||
|
const file = await bucket.getObject('file.txt');
|
||||||
|
|
||||||
|
expect(file.toString()).toEqual('Hello S3');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await s3Instance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- Port: 3003 (default)
|
||||||
|
- Clean slate: true (starts fresh each time)
|
||||||
|
- Full S3-compatible API via [@push.rocks/smarts3](https://code.foss.global/push.rocks/smarts3)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Testing file uploads/downloads
|
||||||
|
- Testing object storage operations
|
||||||
|
- Testing backup/restore logic
|
||||||
|
- Mocking cloud storage
|
||||||
|
|
||||||
|
### TestFileProvider
|
||||||
|
|
||||||
|
Utility for downloading and managing test assets.
|
||||||
|
|
||||||
|
#### `getDockerAlpineImageAsLocalTarball()`
|
||||||
|
|
||||||
|
Download the Alpine Linux Docker image as a tarball for testing.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
|
||||||
|
tap.test('should provide docker image', async () => {
|
||||||
|
const tarballPath = await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
|
||||||
|
|
||||||
|
// Use the tarball path
|
||||||
|
// Path: ./.nogit/testfiles/alpine.tar
|
||||||
|
|
||||||
|
expect(tarballPath).toMatch(/alpine\.tar$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Downloads from https://code.foss.global/testassets/docker
|
||||||
|
- Caches in `.nogit/testfiles/` directory
|
||||||
|
- Returns local file path
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Testing Docker operations
|
||||||
|
- Testing container deployment
|
||||||
|
- Testing image handling logic
|
||||||
|
|
||||||
|
### Path Utilities
|
||||||
|
|
||||||
|
The module exports useful path constants:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as paths from '@git.zone/tstest/tapbundle_node/paths';
|
||||||
|
|
||||||
|
console.log(paths.cwd); // Current working directory
|
||||||
|
console.log(paths.testFilesDir); // ./.nogit/testfiles/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patterns and Best Practices
|
||||||
|
|
||||||
|
### Testing with External Services
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.describe('User Service Integration', () => {
|
||||||
|
let mongoInstance;
|
||||||
|
let db;
|
||||||
|
|
||||||
|
tap.beforeEach(async () => {
|
||||||
|
mongoInstance = await tapNodeTools.createSmartmongo();
|
||||||
|
db = await mongoInstance.getDatabase('testdb');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create user', async () => {
|
||||||
|
const users = await db.getCollection('users');
|
||||||
|
await users.insertOne({ name: 'Bob', email: 'bob@example.com' });
|
||||||
|
|
||||||
|
const user = await users.findOne({ name: 'Bob' });
|
||||||
|
expect(user.email).toEqual('bob@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.afterEach(async () => {
|
||||||
|
await mongoInstance.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing HTTPS Servers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as https from 'https';
|
||||||
|
|
||||||
|
tap.test('should serve over HTTPS', async () => {
|
||||||
|
const { key, cert } = await tapNodeTools.createHttpsCert();
|
||||||
|
|
||||||
|
const server = https.createServer({ key, cert }, (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Secure Response');
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server.listen(8443, () => resolve(undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the server
|
||||||
|
const response = await fetch('https://localhost:8443');
|
||||||
|
const text = await response.text();
|
||||||
|
expect(text).toEqual('Secure Response');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment-Dependent Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
tap.test('should authenticate with GitHub', async () => {
|
||||||
|
const githubToken = await tapNodeTools.getEnvVarOnDemand('GITHUB_TOKEN');
|
||||||
|
|
||||||
|
// Use the token for API calls
|
||||||
|
const response = await fetch('https://api.github.com/user', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${githubToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Requirements
|
||||||
|
|
||||||
|
⚠️ **Node.js Only**: All utilities in this module require Node.js runtime. They will not work in:
|
||||||
|
- Browser environments
|
||||||
|
- Deno runtime
|
||||||
|
- Bun runtime
|
||||||
|
|
||||||
|
For multi-runtime tests, use these utilities only in `.node.ts` test files.
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
Use Node.js-specific file naming when using these utilities:
|
||||||
|
|
||||||
|
```
|
||||||
|
test/mytest.node.ts ✅ Node.js only
|
||||||
|
test/mytest.node+deno.ts ❌ Will fail in Deno
|
||||||
|
test/mytest.browser+node.ts ⚠️ Browser won't have access to these tools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
This module uses the following packages:
|
||||||
|
- [@push.rocks/qenv](https://code.foss.global/push.rocks/qenv) - Environment variable management
|
||||||
|
- [@push.rocks/smartshell](https://code.foss.global/push.rocks/smartshell) - Shell command execution
|
||||||
|
- [@push.rocks/smartcrypto](https://code.foss.global/push.rocks/smartcrypto) - Certificate generation
|
||||||
|
- [@push.rocks/smartmongo](https://code.foss.global/push.rocks/smartmongo) - MongoDB testing
|
||||||
|
- [@push.rocks/smarts3](https://code.foss.global/push.rocks/smarts3) - S3 storage testing
|
||||||
|
- [@push.rocks/smartfile](https://code.foss.global/push.rocks/smartfile) - File operations
|
||||||
|
- [@push.rocks/smartrequest](https://code.foss.global/push.rocks/smartrequest) - HTTP requests
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
This project is licensed under MIT.
|
||||||
|
|
||||||
|
© 2025 Task Venture Capital GmbH. All rights reserved.
|
@@ -269,8 +269,8 @@ export class ProtocolParser {
|
|||||||
|
|
||||||
// Extract simple key:value pairs
|
// 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)}`));
|
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(':')) {
|
if (simpleMatch && simpleMatch[1].includes(':') && !simpleMatch[1].includes('META:') && !simpleMatch[1].includes('SKIP:') && !simpleMatch[1].includes('TODO:') && !simpleMatch[1].includes('EVENT:')) {
|
||||||
// Not a prefixed format, might be key:value pairs
|
// This is a simple key:value format (not a prefixed format)
|
||||||
const pairs = simpleMatch[1].split(',');
|
const pairs = simpleMatch[1].split(',');
|
||||||
for (const pair of pairs) {
|
for (const pair of pairs) {
|
||||||
const [key, value] = pair.split(':');
|
const [key, value] = pair.split(':');
|
||||||
|
587
ts_tapbundle_protocol/readme.md
Normal file
587
ts_tapbundle_protocol/readme.md
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# @git.zone/tstest/tapbundle_protocol
|
||||||
|
|
||||||
|
> 📡 Enhanced TAP Protocol V2 implementation for structured test communication
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tapbundle_protocol is included as part of @git.zone/tstest
|
||||||
|
pnpm install --save-dev @git.zone/tstest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`@git.zone/tstest/tapbundle_protocol` implements Protocol V2, an enhanced version of the Test Anything Protocol (TAP) with support for structured metadata, real-time events, error diffs, and isomorphic operation. This protocol enables rich communication between test runners and test consumers while maintaining backward compatibility with standard TAP parsers.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- 📋 **TAP v13 Compliant** - Fully compatible with standard TAP consumers
|
||||||
|
- 🎯 **Enhanced Metadata** - Timing, tags, errors, diffs, and custom data
|
||||||
|
- 🔄 **Real-time Events** - Live test execution updates
|
||||||
|
- 🔍 **Structured Errors** - JSON error blocks with stack traces and diffs
|
||||||
|
- 📸 **Snapshot Support** - Built-in snapshot testing protocol
|
||||||
|
- 🌐 **Isomorphic** - Works in Node.js, browsers, Deno, and Bun
|
||||||
|
- 🏷️ **Protocol Markers** - Structured data using Unicode delimiters
|
||||||
|
|
||||||
|
## Protocol V2 Format
|
||||||
|
|
||||||
|
### Protocol Markers
|
||||||
|
|
||||||
|
Protocol V2 uses special Unicode markers to embed structured data within TAP output:
|
||||||
|
|
||||||
|
- `⟦TSTEST:` - Start marker
|
||||||
|
- `⟧` - End marker
|
||||||
|
|
||||||
|
These markers allow structured data to coexist with standard TAP without breaking compatibility.
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```tap
|
||||||
|
⟦TSTEST:PROTOCOL:2.0.0⟧
|
||||||
|
TAP version 13
|
||||||
|
1..3
|
||||||
|
ok 1 - should add numbers ⟦TSTEST:time:42⟧
|
||||||
|
not ok 2 - should validate input
|
||||||
|
⟦TSTEST:META:{"time":156,"file":"test.ts","line":42}⟧
|
||||||
|
⟦TSTEST:ERROR⟧
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Expected 5 to equal 6",
|
||||||
|
"diff": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
⟦TSTEST:/ERROR⟧
|
||||||
|
ok 3 - should handle edge cases # SKIP not implemented ⟦TSTEST:SKIP:not implemented⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### ProtocolEmitter
|
||||||
|
|
||||||
|
Generates Protocol V2 messages. Used by tapbundle to emit test results.
|
||||||
|
|
||||||
|
#### `emitProtocolHeader()`
|
||||||
|
|
||||||
|
Emit the protocol version header.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProtocolEmitter } from '@git.zone/tstest/tapbundle_protocol';
|
||||||
|
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
console.log(emitter.emitProtocolHeader());
|
||||||
|
// Output: ⟦TSTEST:PROTOCOL:2.0.0⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitTapVersion(version?)`
|
||||||
|
|
||||||
|
Emit TAP version line.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(emitter.emitTapVersion(13));
|
||||||
|
// Output: TAP version 13
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitPlan(plan)`
|
||||||
|
|
||||||
|
Emit test plan.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(emitter.emitPlan({ start: 1, end: 5 }));
|
||||||
|
// Output: 1..5
|
||||||
|
|
||||||
|
console.log(emitter.emitPlan({ start: 1, end: 0, skipAll: 'Not ready' }));
|
||||||
|
// Output: 1..0 # Skipped: Not ready
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitTest(result)`
|
||||||
|
|
||||||
|
Emit a test result with optional metadata.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const lines = emitter.emitTest({
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'should work correctly',
|
||||||
|
metadata: {
|
||||||
|
time: 45,
|
||||||
|
tags: ['unit', 'fast']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.forEach(line => console.log(line));
|
||||||
|
// Output:
|
||||||
|
// ok 1 - should work correctly ⟦TSTEST:time:45⟧
|
||||||
|
// ⟦TSTEST:META:{"tags":["unit","fast"]}⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitComment(comment)`
|
||||||
|
|
||||||
|
Emit a comment line.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(emitter.emitComment('Setup complete'));
|
||||||
|
// Output: # Setup complete
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitBailout(reason)`
|
||||||
|
|
||||||
|
Emit a bailout (abort all tests).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(emitter.emitBailout('Database connection failed'));
|
||||||
|
// Output: Bail out! Database connection failed
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitError(error)`
|
||||||
|
|
||||||
|
Emit a structured error block.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const lines = emitter.emitError({
|
||||||
|
testNumber: 2,
|
||||||
|
error: {
|
||||||
|
message: 'Expected 5 to equal 6',
|
||||||
|
stack: 'Error: ...',
|
||||||
|
actual: 5,
|
||||||
|
expected: 6,
|
||||||
|
diff: '...'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.forEach(line => console.log(line));
|
||||||
|
// Output:
|
||||||
|
// ⟦TSTEST:ERROR⟧
|
||||||
|
// {
|
||||||
|
// "testNumber": 2,
|
||||||
|
// "error": { ... }
|
||||||
|
// }
|
||||||
|
// ⟦TSTEST:/ERROR⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitEvent(event)`
|
||||||
|
|
||||||
|
Emit a real-time test event.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(emitter.emitEvent({
|
||||||
|
eventType: 'test:started',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'should work'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// Output: ⟦TSTEST:EVENT:{"eventType":"test:started",...}⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitSnapshot(snapshot)`
|
||||||
|
|
||||||
|
Emit snapshot data.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const lines = emitter.emitSnapshot({
|
||||||
|
name: 'user-data',
|
||||||
|
content: { name: 'Alice', age: 30 },
|
||||||
|
format: 'json'
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.forEach(line => console.log(line));
|
||||||
|
// Output:
|
||||||
|
// ⟦TSTEST:SNAPSHOT:user-data⟧
|
||||||
|
// {
|
||||||
|
// "name": "Alice",
|
||||||
|
// "age": 30
|
||||||
|
// }
|
||||||
|
// ⟦TSTEST:/SNAPSHOT⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
### ProtocolParser
|
||||||
|
|
||||||
|
Parses Protocol V2 messages. Used by tstest to consume test results.
|
||||||
|
|
||||||
|
#### `parseLine(line)`
|
||||||
|
|
||||||
|
Parse a single line and return protocol messages.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProtocolParser } from '@git.zone/tstest/tapbundle_protocol';
|
||||||
|
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const messages = parser.parseLine('ok 1 - test passed ⟦TSTEST:time:42⟧');
|
||||||
|
console.log(messages);
|
||||||
|
// Output:
|
||||||
|
// [{
|
||||||
|
// type: 'test',
|
||||||
|
// content: {
|
||||||
|
// ok: true,
|
||||||
|
// testNumber: 1,
|
||||||
|
// description: 'test passed',
|
||||||
|
// metadata: { time: 42 }
|
||||||
|
// }
|
||||||
|
// }]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Message Types
|
||||||
|
|
||||||
|
The parser returns different message types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IProtocolMessage {
|
||||||
|
type: 'test' | 'plan' | 'comment' | 'version' | 'bailout' | 'protocol' | 'snapshot' | 'error' | 'event';
|
||||||
|
content: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Test result
|
||||||
|
{
|
||||||
|
type: 'test',
|
||||||
|
content: {
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'test name',
|
||||||
|
metadata: { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan
|
||||||
|
{
|
||||||
|
type: 'plan',
|
||||||
|
content: {
|
||||||
|
start: 1,
|
||||||
|
end: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event
|
||||||
|
{
|
||||||
|
type: 'event',
|
||||||
|
content: {
|
||||||
|
eventType: 'test:started',
|
||||||
|
timestamp: 1234567890,
|
||||||
|
data: { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
content: {
|
||||||
|
testNumber: 2,
|
||||||
|
error: {
|
||||||
|
message: '...',
|
||||||
|
stack: '...',
|
||||||
|
diff: '...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getProtocolVersion()`
|
||||||
|
|
||||||
|
Get the detected protocol version.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const version = parser.getProtocolVersion();
|
||||||
|
console.log(version); // "2.0.0" or null
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
### ITestResult
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITestResult {
|
||||||
|
ok: boolean;
|
||||||
|
testNumber: number;
|
||||||
|
description: string;
|
||||||
|
directive?: {
|
||||||
|
type: 'skip' | 'todo';
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
metadata?: ITestMetadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ITestMetadata
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITestMetadata {
|
||||||
|
// Timing
|
||||||
|
time?: number; // Test duration in milliseconds
|
||||||
|
startTime?: number; // Unix timestamp
|
||||||
|
endTime?: number; // Unix timestamp
|
||||||
|
|
||||||
|
// Status
|
||||||
|
skip?: string; // Skip reason
|
||||||
|
todo?: string; // Todo reason
|
||||||
|
retry?: number; // Current retry attempt
|
||||||
|
maxRetries?: number; // Max retries allowed
|
||||||
|
|
||||||
|
// Error details
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
diff?: string;
|
||||||
|
actual?: any;
|
||||||
|
expected?: any;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test context
|
||||||
|
file?: string; // Source file path
|
||||||
|
line?: number; // Line number
|
||||||
|
column?: number; // Column number
|
||||||
|
|
||||||
|
// Custom data
|
||||||
|
tags?: string[]; // Test tags
|
||||||
|
custom?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ITestEvent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITestEvent {
|
||||||
|
eventType: EventType;
|
||||||
|
timestamp: number;
|
||||||
|
data: {
|
||||||
|
testNumber?: number;
|
||||||
|
description?: string;
|
||||||
|
suiteName?: string;
|
||||||
|
hookName?: string;
|
||||||
|
progress?: number; // 0-100
|
||||||
|
duration?: number;
|
||||||
|
error?: IEnhancedError;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventType =
|
||||||
|
| 'test:queued'
|
||||||
|
| 'test:started'
|
||||||
|
| 'test:progress'
|
||||||
|
| 'test:completed'
|
||||||
|
| 'suite:started'
|
||||||
|
| 'suite:completed'
|
||||||
|
| 'hook:started'
|
||||||
|
| 'hook:completed'
|
||||||
|
| 'assertion:failed';
|
||||||
|
```
|
||||||
|
|
||||||
|
### IEnhancedError
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IEnhancedError {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
diff?: IDiffResult;
|
||||||
|
actual?: any;
|
||||||
|
expected?: any;
|
||||||
|
code?: string;
|
||||||
|
type?: 'assertion' | 'timeout' | 'uncaught' | 'syntax' | 'runtime';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IDiffResult
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IDiffResult {
|
||||||
|
type: 'string' | 'object' | 'array' | 'primitive';
|
||||||
|
changes: IDiffChange[];
|
||||||
|
context?: number; // Lines of context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDiffChange {
|
||||||
|
type: 'add' | 'remove' | 'modify';
|
||||||
|
path?: string[]; // For object/array diffs
|
||||||
|
oldValue?: any;
|
||||||
|
newValue?: any;
|
||||||
|
line?: number; // For string diffs
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Constants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PROTOCOL_MARKERS, PROTOCOL_VERSION } from '@git.zone/tstest/tapbundle_protocol';
|
||||||
|
|
||||||
|
console.log(PROTOCOL_VERSION); // "2.0.0"
|
||||||
|
console.log(PROTOCOL_MARKERS.START); // "⟦TSTEST:"
|
||||||
|
console.log(PROTOCOL_MARKERS.END); // "⟧"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Markers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const PROTOCOL_MARKERS = {
|
||||||
|
START: '⟦TSTEST:',
|
||||||
|
END: '⟧',
|
||||||
|
META_PREFIX: 'META:',
|
||||||
|
ERROR_PREFIX: 'ERROR',
|
||||||
|
ERROR_END: '/ERROR',
|
||||||
|
SNAPSHOT_PREFIX: 'SNAPSHOT:',
|
||||||
|
SNAPSHOT_END: '/SNAPSHOT',
|
||||||
|
PROTOCOL_PREFIX: 'PROTOCOL:',
|
||||||
|
SKIP_PREFIX: 'SKIP:',
|
||||||
|
TODO_PREFIX: 'TODO:',
|
||||||
|
EVENT_PREFIX: 'EVENT:',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Patterns
|
||||||
|
|
||||||
|
### Creating a Custom Test Runner
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProtocolEmitter } from '@git.zone/tstest/tapbundle_protocol';
|
||||||
|
|
||||||
|
const emitter = new ProtocolEmitter();
|
||||||
|
|
||||||
|
// Emit protocol header
|
||||||
|
console.log(emitter.emitProtocolHeader());
|
||||||
|
console.log(emitter.emitTapVersion(13));
|
||||||
|
|
||||||
|
// Emit plan
|
||||||
|
console.log(emitter.emitPlan({ start: 1, end: 2 }));
|
||||||
|
|
||||||
|
// Run test 1
|
||||||
|
emitter.emitEvent({
|
||||||
|
eventType: 'test:started',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: { testNumber: 1 }
|
||||||
|
}).split('\n').forEach(line => console.log(line));
|
||||||
|
|
||||||
|
const result1 = emitter.emitTest({
|
||||||
|
ok: true,
|
||||||
|
testNumber: 1,
|
||||||
|
description: 'first test',
|
||||||
|
metadata: { time: 45 }
|
||||||
|
});
|
||||||
|
result1.forEach(line => console.log(line));
|
||||||
|
|
||||||
|
// Run test 2
|
||||||
|
const result2 = emitter.emitTest({
|
||||||
|
ok: false,
|
||||||
|
testNumber: 2,
|
||||||
|
description: 'second test',
|
||||||
|
metadata: {
|
||||||
|
time: 120,
|
||||||
|
error: {
|
||||||
|
message: 'Assertion failed',
|
||||||
|
actual: 5,
|
||||||
|
expected: 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
result2.forEach(line => console.log(line));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parsing Test Output
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProtocolParser } from '@git.zone/tstest/tapbundle_protocol';
|
||||||
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
const messages = parser.parseLine(line);
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'test':
|
||||||
|
console.log(`Test ${message.content.testNumber}: ${message.content.ok ? 'PASS' : 'FAIL'}`);
|
||||||
|
break;
|
||||||
|
case 'event':
|
||||||
|
console.log(`Event: ${message.content.eventType}`);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
console.error(`Error: ${message.content.error.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Test Dashboards
|
||||||
|
|
||||||
|
Real-time events enable building live test dashboards:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const parser = new ProtocolParser();
|
||||||
|
|
||||||
|
parser.parseLine(line).forEach(message => {
|
||||||
|
if (message.type === 'event') {
|
||||||
|
const event = message.content;
|
||||||
|
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'test:started':
|
||||||
|
updateUI({ status: 'running', test: event.data.description });
|
||||||
|
break;
|
||||||
|
case 'test:completed':
|
||||||
|
updateUI({ status: 'done', duration: event.data.duration });
|
||||||
|
break;
|
||||||
|
case 'suite:started':
|
||||||
|
createSuiteCard(event.data.suiteName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
Protocol V2 is fully backward compatible with standard TAP parsers:
|
||||||
|
|
||||||
|
- Protocol markers use Unicode characters that TAP parsers ignore
|
||||||
|
- Standard TAP output (ok/not ok, plan, comments) works everywhere
|
||||||
|
- Enhanced features gracefully degrade in standard TAP consumers
|
||||||
|
|
||||||
|
**Standard TAP View:**
|
||||||
|
```tap
|
||||||
|
TAP version 13
|
||||||
|
1..3
|
||||||
|
ok 1 - should add numbers
|
||||||
|
not ok 2 - should validate input
|
||||||
|
ok 3 - should handle edge cases # SKIP not implemented
|
||||||
|
```
|
||||||
|
|
||||||
|
**Protocol V2 View (same output):**
|
||||||
|
```tap
|
||||||
|
⟦TSTEST:PROTOCOL:2.0.0⟧
|
||||||
|
TAP version 13
|
||||||
|
1..3
|
||||||
|
ok 1 - should add numbers ⟦TSTEST:time:42⟧
|
||||||
|
not ok 2 - should validate input
|
||||||
|
⟦TSTEST:META:{"time":156}⟧
|
||||||
|
ok 3 - should handle edge cases # SKIP not implemented ⟦TSTEST:SKIP:not implemented⟧
|
||||||
|
```
|
||||||
|
|
||||||
|
## Isomorphic Design
|
||||||
|
|
||||||
|
This module works in all JavaScript environments:
|
||||||
|
|
||||||
|
- ✅ Node.js
|
||||||
|
- ✅ Browsers (via tapbundle)
|
||||||
|
- ✅ Deno
|
||||||
|
- ✅ Bun
|
||||||
|
- ✅ Web Workers
|
||||||
|
- ✅ Service Workers
|
||||||
|
|
||||||
|
No runtime-specific APIs are used, making it truly portable.
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
This project is licensed under MIT.
|
||||||
|
|
||||||
|
© 2025 Task Venture Capital GmbH. All rights reserved.
|
Reference in New Issue
Block a user