Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e53e5143e | |||
| cf4e5c33e8 | |||
| bc39793682 | |||
| edce15b20a | |||
| 9d34a3511a | |||
| 300e03628c | |||
| 893d6e40dc | |||
| 8b16ba1d9a | |||
| 8c41d18f84 | |||
| 69263b3efc | |||
| 4b4ec78328 | |||
| f23c902658 | |||
| 4d1896bdf9 | |||
| fdc84a2d83 | |||
| d92850e1d2 | |||
| 6c498b3686 | |||
| 913c3cafe8 | |||
| 9ec2c8b6eb | |||
| 286030a08d | |||
| 46f0a5a8cf | |||
| ae59b7adf2 | |||
| 2b81e8e5aa | |||
| c9950d31ac | |||
| d6f657a46a | |||
| 0379bd7288 | |||
| 9ab4f1838d | |||
| b9016206ce | |||
| 8edbbd4850 | |||
| 97c91fc010 | |||
| ca08bb2e3c | |||
| 8fd114334f | |||
| c630a171b5 | |||
| 1a4eb5b6d9 | |||
| 41d7c1ce49 | |||
| 3ab5550cb8 | |||
| ee7b387534 | |||
| 7e67b64a6e | |||
| 1ce730d4f2 | |||
| 9357d6e7ef | |||
| 973ce771d2 | |||
| 8441881d92 | |||
| 16ca3b6374 | |||
| b94089652e | |||
| ef6f21fc9c | |||
| 592a4f33c0 | |||
| 1ea3b37d18 | |||
| 062c6e384b |
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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"
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -22,5 +22,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"deno.enable": false
|
||||
}
|
||||
|
||||
177
changelog.md
177
changelog.md
@@ -1,5 +1,182 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-19 - 3.5.0 - feat(tstest)
|
||||
add support for package.json before scripts during test execution
|
||||
|
||||
- load test:before or test:before:once once per test run from package.json scripts
|
||||
- run test:before:testfile before each test file execution and pass TSTEST_FILE and TSTEST_RUNTIME environment variables
|
||||
- log before-script lifecycle events and abort or skip execution when setup scripts fail
|
||||
|
||||
## 2026-03-18 - 3.4.0 - feat(tapbundle,deno)
|
||||
replace smarts3 test tooling with smartstorage and pre-resolve Deno test dependencies
|
||||
|
||||
- switch TapNodeTools storage helper from @push.rocks/smarts3 to @push.rocks/smartstorage and rename createSmarts3() to createSmartStorage()
|
||||
- update node tests and tapbundle server-side documentation to use the new smartstorage helper
|
||||
- run `deno install --entrypoint` before executing Deno tests to resolve dependencies up front
|
||||
- bump supporting development dependencies including @types/node and @push.rocks/smartshell
|
||||
|
||||
## 2026-03-09 - 3.3.2 - fix(deps)
|
||||
bump dependency versions and reorder smartserve in package.json
|
||||
|
||||
- bump @push.rocks/smartbrowser from ^2.0.10 to ^2.0.11
|
||||
- bump @push.rocks/smartfs from ^1.4.0 to ^1.5.0
|
||||
- move @push.rocks/smartserve to later position in dependencies (version unchanged: ^2.0.1)
|
||||
|
||||
## 2026-03-09 - 3.3.1 - fix(serve)
|
||||
migrate test HTTP server to @push.rocks/smartserve and update related dependencies
|
||||
|
||||
- Replace @api.global/typedserver with @push.rocks/smartserve and FileServer; use SmartServe.setHandler to serve static assets and a custom /test response.
|
||||
- Export smartserve from ts/tstest.plugins.ts and remove typedserver import/export.
|
||||
- Update package.json dependencies: add @push.rocks/smartserve@^2.0.1 and bump @push.rocks/smartbrowser to ^2.0.10.
|
||||
|
||||
## 2026-03-06 - 3.3.0 - feat(testfile-directives)
|
||||
Add per-test file directives to control runtime permissions and flags (Deno, Node, Bun, Chromium)
|
||||
|
||||
- Introduce test file directive parser (ts/tstest.classes.testfile.directives.ts) to parse comments like // tstest:deno:allowAll and map them to runtime options.
|
||||
- Add DENO_DEFAULT_PERMISSIONS constant and centralize Deno default flags (ts/tstest.classes.runtime.deno.ts) to avoid repeating the list.
|
||||
- Integrate directives into the test runner (ts/tstest.classes.tstest.ts): read directives from test files and optional 00init.ts, merge them, and pass runtime-specific options to adapters.
|
||||
- Documentation: add a "Test File Directives" section to readme.md with examples and available directives.
|
||||
- Add automated tests for directives behavior (test/test.directives.node.ts).
|
||||
- Bump package metadata and minor dependency updates; update package description and npmextra.json to reflect new functionality.
|
||||
|
||||
## 2026-03-03 - 3.2.0 - feat(tapbundle_serverside)
|
||||
add network port discovery utilities and migrate file I/O to smartfs; refactor runtimes to use Node fs and SmartFs, update server APIs and bump dependencies
|
||||
|
||||
- Add tapNodeTools.findFreePort, findFreePorts and findFreePortRange to provide network port discovery and ranges for tests
|
||||
- Integrate @push.rocks/smartfs (smartfsInstance) and replace many @push.rocks/smartfile.fs usages with smartfsInstance file/directory APIs
|
||||
- Switch several internals to Node's fs (exported via plugins) and introduce SmartFileFactory.nodeFs() for file handling
|
||||
- Replace smartchok with smartwatch for file watching and update watch/start/stop flows
|
||||
- Update server instantiation to TypedServer and change addRoute usage to the new handler signature; serve bundled test directory
|
||||
- Add tests for network tools and update migration/test code to use smartfsInstance
|
||||
- Bump multiple dependencies (e.g. @api.global/typedserver, @push.rocks/smartfile/smartfs/smartnetwork/smarts3/smartwatch) and @git.zone/tsbuild
|
||||
|
||||
## 2026-01-25 - 3.1.8 - fix(tapbundle)
|
||||
treat tests that call tools.allowFailure() as passing and update tests to use tools parameter
|
||||
|
||||
- Set testResult.ok to this.failureAllowed so allowed failures are considered passing in the tap test runner implementation (ts_tapbundle/tapbundle.classes.taptest.ts).
|
||||
- Updated multiple tests to accept the tools parameter and call tools.allowFailure() where failures are intended (test/tapbundle/test.performance-metrics.ts, test/tstest/test.fail.ts, test/tstest/test.failing-with-logs.ts).
|
||||
- Prevents intentionally-failing tests from skewing timing/metric calculations and preserves console logs for allowed failures.
|
||||
|
||||
## 2026-01-25 - 3.1.7 - fix(tap-parser)
|
||||
append newline to WebSocket tap log messages to ensure proper line-by-line processing
|
||||
|
||||
- Fixes handling of WebSocket console.log messages by appending a trailing newline before processing to avoid merged lines.
|
||||
- Modified ts/tstest.classes.tap.parser.ts: handleTapLog now calls _processLog(tapLog + '\n').
|
||||
|
||||
## 2026-01-19 - 3.1.6 - fix(logging)
|
||||
handle mid-line streaming output in test logger and add streaming tests
|
||||
|
||||
- Introduce isOutputMidLine flag to track when streaming output does not end with a newline
|
||||
- Only prepend the visual prefix at the start of a line and append segments to the last buffered entry when mid-line
|
||||
- Write consistent output to log files for both complete lines and raw streaming segments
|
||||
- Add tests to exercise streaming behavior: test/tstest/test.gap-debug.ts and test/tstest/test.gap-debug2.ts
|
||||
|
||||
## 2026-01-19 - 3.1.5 - fix(tstest)
|
||||
preserve streaming console output and correctly buffer incomplete TAP lines
|
||||
|
||||
- Reworked TapParser._processLog to buffer incomplete lines and only parse complete TAP protocol lines
|
||||
- Added TapParser.lineBuffer and _looksLikeTapStart() to detect and buffer starts of TAP messages
|
||||
- Added TapParser._handleConsoleOutput() to centralize console output handling and snapshot parsing; flushes buffered content on process exit
|
||||
- Added TapTestResult.addLogLineRaw() to append streaming text without adding newlines
|
||||
- Added TsTestLogger.testConsoleOutputStreaming() and logToTestFileRaw() to preserve streaming output formatting in both console and logfile
|
||||
|
||||
## 2025-12-30 - 3.1.4 - fix(webhelpers)
|
||||
improve browser test fixture to append element and await custom element upgrade and Lit update completion; add generic return type; update npm packaging release config; remove pnpm onlyBuiltDependencies
|
||||
|
||||
- ts_tapbundle/webhelpers.ts: make fixture generic and return T; append created element to document; await customElements.whenDefined for custom elements and await updateComplete for Lit/async components to ensure stable rendering in tests
|
||||
- npmextra.json: add @git.zone/cli module metadata and release.registries/accessLevel; add @ship.zone/szci entry
|
||||
- pnpm-workspace.yaml: remove onlyBuiltDependencies entries
|
||||
|
||||
## 2025-11-21 - 3.1.3 - fix(docs)
|
||||
Update package author and expand license/legal and issue-reporting information in tapbundle docs
|
||||
|
||||
- Update package.json author field to Task Venture Capital GmbH
|
||||
- Add expanded License and Legal Information to ts_tapbundle/readme.md (clarifies MIT license scope and trademark guidance)
|
||||
- Add expanded License and Legal Information to ts_tapbundle_protocol/readme.md (clarifies MIT license scope and trademark guidance)
|
||||
- Add Issue Reporting and Security section to ts_tapbundle_protocol/readme.md pointing users to the community hub for bug/security reports
|
||||
- Include company information and trademark usage guidance in readmes
|
||||
|
||||
## 2025-11-21 - 3.1.2 - fix(docs)
|
||||
Update README: add issue reporting/security guidance and expanded changelog (3.1.1/3.1.0)
|
||||
|
||||
- Add 'Issue Reporting and Security' section pointing to https://community.foss.global/ for bug/security reports and contributor onboarding.
|
||||
- Expand Changelog with Version 3.1.1 notes: fixed TapTools parameter passing to suite lifecycle hooks (beforeAll/afterAll) and updated @push.rocks/smarts3 dependency to ^3.0.0.
|
||||
- Include Changelog entries for Version 3.1.0: postTask() API, suite beforeAll/afterAll, new parallel() fluent API, and enhanced tapbundle documentation.
|
||||
- Documentation-only change (no source code modifications).
|
||||
|
||||
## 2025-11-21 - 3.1.1 - fix(tapbundle)
|
||||
Pass TapTools to suite lifecycle hooks (beforeAll/afterAll) and update @push.rocks/smarts3 to ^3.0.0
|
||||
|
||||
- Replace usage of a Deferred promise with a TapTools instance when invoking suite.beforeAll and suite.afterAll
|
||||
- Add import for TapTools in ts_tapbundle/tapbundle.classes.tap.ts
|
||||
- Bump dependency @push.rocks/smarts3 from ^2.2.7 to ^3.0.0 in package.json
|
||||
|
||||
## 2025-11-20 - 3.1.0 - feat(tapbundle)
|
||||
Add global postTask (teardown) and suite lifecycle hooks (beforeAll/afterAll) to tapbundle
|
||||
|
||||
- Introduce PostTask class (ts_tapbundle/tapbundle.classes.posttask.ts) and tap.postTask() API for global teardown.
|
||||
- Integrate postTask execution into Tap.start() so postTasks run after all tests and before the global afterAll hook.
|
||||
- Add suite-level beforeAll and afterAll support and ensure afterAll runs after child suites and their tests (changes in ts_tapbundle/tapbundle.classes.tap.ts).
|
||||
- Add lifecycle tests (test/tapbundle/test.new-lifecycle.ts) verifying execution order, including parallel tests.
|
||||
- Update documentation (readme.hints.md) describing Phase 1 API improvements and usage notes.
|
||||
- This is additive and backward-compatible (no breaking changes).
|
||||
|
||||
## 2025-11-20 - 3.0.1 - fix(@push.rocks/smarts3)
|
||||
Bump @push.rocks/smarts3 dependency to ^2.2.7
|
||||
|
||||
- Update package.json: @push.rocks/smarts3 upgraded from ^2.2.6 to ^2.2.7
|
||||
|
||||
## 2025-11-19 - 3.0.0 - BREAKING CHANGE(tapbundle_serverside)
|
||||
Rename Node-specific tapbundle module to tapbundle_serverside and migrate server-side utilities
|
||||
|
||||
- Change public export in package.json from ./tapbundle_node to ./tapbundle_serverside — consumers must update imports to @git.zone/tstest/tapbundle_serverside
|
||||
- Move and re-create Node-only implementation files under ts_tapbundle_serverside (plugins, paths, classes.tapnodetools, classes.testfileprovider, index, tspublish.json) and remove legacy ts_tapbundle_node sources
|
||||
- Update internal imports and tests to reference the new tapbundle_serverside path (e.g. test/tapbundle/test.node.ts updated)
|
||||
- Update documentation (readme.md and readme.hints.md) to describe the new tapbundle_serverside export and its server-side utilities
|
||||
- Ensure build outputs and publish metadata reflect the new module directory (tspublish.json order preserved)
|
||||
|
||||
## 2025-11-19 - 2.8.3 - fix(dependencies)
|
||||
Update dependency versions
|
||||
|
||||
- Bump devDependency @git.zone/tsbuild to ^3.1.0
|
||||
- Upgrade @git.zone/tsrun to ^2.0.0 (major)
|
||||
- Upgrade @push.rocks/smartenv to ^6.0.0 (major)
|
||||
- Upgrade @push.rocks/smartrequest to ^5.0.1 (major/feature in dependency)
|
||||
- Patch updates: @api.global/typedserver → ^3.0.80, @git.zone/tsbundle → ^2.5.2, @push.rocks/smartmongo → ^2.0.14
|
||||
|
||||
## 2025-11-17 - 2.8.2 - fix(logging)
|
||||
Include runtime identifier in per-test logfile name and sanitize runtime string
|
||||
|
||||
- Append a sanitized runtime identifier to the per-test log filename (format: <safeFilename>__<safeRuntime>.log) so runs for different runtimes don't clash
|
||||
- Sanitize runtime names by lowercasing and removing non-alphanumeric characters to produce filesystem-safe filenames
|
||||
|
||||
## 2025-11-17 - 2.8.1 - fix(config)
|
||||
Remove Bun config file and set deno.json useDefineForClassFields to false for compatibility
|
||||
|
||||
- Removed bunfig.toml (Bun-specific TypeScript decorator configuration) — stops shipping a project-local Bun transpiler config.
|
||||
- Updated deno.json: set compilerOptions.useDefineForClassFields = false to keep legacy class field semantics and avoid runtime/emit incompatibilities in Deno.
|
||||
|
||||
## 2025-11-17 - 2.8.0 - feat(runtime-adapters)
|
||||
Enable TypeScript decorator support for Deno and Bun runtimes and add decorator tests
|
||||
|
||||
- Add bunfig.toml to enable experimentalDecorators for Bun runtime
|
||||
- Add deno.json to enable experimentalDecorators and set target/lib for Deno
|
||||
- Update Bun runtime adapter to note bunfig.toml discovery so Bun runs with decorator support
|
||||
- Update Deno runtime adapter to auto-detect deno.json / deno.jsonc and pass configPath in default options
|
||||
- Add integration tests for decorators (test/decorator.all.ts) to verify decorator support across runtimes
|
||||
|
||||
## 2025-10-26 - 2.7.0 - feat(tapbundle_protocol)
|
||||
Add package export for tapbundle_protocol to expose protocol utilities
|
||||
|
||||
- Add './tapbundle_protocol' export in package.json pointing to './dist_ts_tapbundle_protocol/index.js'.
|
||||
- Allows consumers to import protocol utilities (ProtocolEmitter, ProtocolParser, types) via '@git.zone/tstest/tapbundle_protocol'.
|
||||
- Non-breaking: only extends package exports surface.
|
||||
|
||||
## 2025-10-17 - 2.6.2 - fix(@push.rocks/smartrequest)
|
||||
Bump @push.rocks/smartrequest from ^4.3.1 to ^4.3.2
|
||||
|
||||
- Update dependency @push.rocks/smartrequest from ^4.3.1 to ^4.3.2
|
||||
|
||||
## 2025-10-17 - 2.6.1 - fix(runtime-adapters)
|
||||
Silence shell version checks for Bun and Deno; add local Claude settings
|
||||
|
||||
|
||||
13
deno.json
Normal file
13
deno.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ES2022"
|
||||
},
|
||||
"nodeModulesDir": true,
|
||||
"version": "3.5.0"
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "git.zone",
|
||||
"gitrepo": "tstest",
|
||||
"description": "a test utility to run tests that match test/**/*.ts",
|
||||
"description": "A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.",
|
||||
"npmPackagename": "@git.zone/tstest",
|
||||
"license": "MIT"
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
44
package.json
44
package.json
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@git.zone/tstest",
|
||||
"version": "2.6.1",
|
||||
"version": "3.5.0",
|
||||
"private": false,
|
||||
"description": "a test utility to run tests that match test/**/*.ts",
|
||||
"description": "A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./tapbundle": "./dist_ts_tapbundle/index.js",
|
||||
"./tapbundle_node": "./dist_ts_tapbundle_node/index.js"
|
||||
"./tapbundle_serverside": "./dist_ts_tapbundle_serverside/index.js",
|
||||
"./tapbundle_protocol": "./dist_ts_tapbundle_protocol/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "Lossless GmbH",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tstest": "./cli.js"
|
||||
@@ -24,35 +25,36 @@
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@types/node": "^22.15.21"
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedserver": "^3.0.79",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tsrun": "^1.6.2",
|
||||
"@git.zone/tsbundle": "^2.9.1",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@push.rocks/consolecolor": "^2.0.3",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartbrowser": "^2.0.8",
|
||||
"@push.rocks/smartchok": "^1.1.1",
|
||||
"@push.rocks/smartbrowser": "^2.0.11",
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartenv": "^5.0.13",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@push.rocks/smartexpect": "^2.5.0",
|
||||
"@push.rocks/smartfile": "^11.2.7",
|
||||
"@push.rocks/smartjson": "^5.2.0",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmongo": "^2.0.12",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^4.3.1",
|
||||
"@push.rocks/smarts3": "^2.2.6",
|
||||
"@push.rocks/smartshell": "^3.3.0",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartserve": "^2.0.1",
|
||||
"@push.rocks/smartshell": "^3.3.8",
|
||||
"@push.rocks/smartstorage": "^6.0.1",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartwatch": "^6.3.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"figures": "^6.1.0",
|
||||
"ws": "^8.18.3"
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
7355
pnpm-lock.yaml
generated
7355
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
||||
178
readme.hints.md
178
readme.hints.md
@@ -6,7 +6,7 @@ This project integrates tstest with tapbundle through a modular architecture:
|
||||
|
||||
1. **tstest** (`/ts/`) - The test runner that discovers and executes test files
|
||||
2. **tapbundle** (`/ts_tapbundle/`) - The TAP testing framework for writing tests
|
||||
3. **tapbundle_node** (`/ts_tapbundle_node/`) - Node.js-specific testing utilities
|
||||
3. **tapbundle_serverside** (`/ts_tapbundle_serverside/`) - Server-side testing utilities (network port finding, runCommand, env vars, HTTPS certs, MongoDB, S3, test assets)
|
||||
|
||||
## How Components Work Together
|
||||
|
||||
@@ -31,7 +31,7 @@ This project integrates tstest with tapbundle through a modular architecture:
|
||||
|
||||
1. **Import Structure**
|
||||
- Test files import from local tapbundle: `import { tap, expect } from '../../ts_tapbundle/index.js'`
|
||||
- Node-specific tests also import from tapbundle_node: `import { tapNodeTools } from '../../ts_tapbundle_node/index.js'`
|
||||
- Server-side tests also import from tapbundle_serverside for Node.js-only utilities: `import { tapNodeTools } from '../../ts_tapbundle_serverside/index.js'`
|
||||
|
||||
2. **WebHelpers**
|
||||
- Browser tests can use webhelpers for DOM manipulation
|
||||
@@ -41,7 +41,7 @@ This project integrates tstest with tapbundle through a modular architecture:
|
||||
|
||||
3. **Build System**
|
||||
- Uses `tsbuild tsfolders` to compile TypeScript (invoked by `pnpm build`)
|
||||
- Maintains separate output directories: `/dist_ts/`, `/dist_ts_tapbundle/`, `/dist_ts_tapbundle_node/`, `/dist_ts_tapbundle_protocol/`
|
||||
- Maintains separate output directories: `/dist_ts/`, `/dist_ts_tapbundle/`, `/dist_ts_tapbundle_serverside/`, `/dist_ts_tapbundle_protocol/`
|
||||
- Compilation order is resolved automatically based on dependencies in tspublish.json files
|
||||
- Protocol imports use compiled dist directories:
|
||||
```typescript
|
||||
@@ -244,6 +244,131 @@ tstest test/specific.ts -w
|
||||
- Ignores changes matching the ignore patterns
|
||||
- Shows "Waiting for file changes..." between runs
|
||||
|
||||
## Phase 1 API Improvements (v3.1.0)
|
||||
|
||||
### New Features Implemented
|
||||
|
||||
#### 1. tap.postTask() - Global Teardown (COMPLETED)
|
||||
|
||||
Added symmetric teardown method to complement `tap.preTask()`:
|
||||
|
||||
**Implementation:**
|
||||
- Created `PostTask` class in `ts_tapbundle/tapbundle.classes.posttask.ts`
|
||||
- Mirrors PreTask structure with description and function
|
||||
- Integrated into Tap class execution flow
|
||||
- Runs after all tests complete but before global `afterAll` hook
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
tap.postTask('cleanup database', async () => {
|
||||
await cleanupDatabase();
|
||||
});
|
||||
```
|
||||
|
||||
**Execution Order:**
|
||||
1. preTask hooks
|
||||
2. Global beforeAll
|
||||
3. Tests (with suite hooks)
|
||||
4. **postTask hooks** ← NEW
|
||||
5. Global afterAll
|
||||
|
||||
#### 2. Suite-Level beforeAll/afterAll (COMPLETED)
|
||||
|
||||
Added once-per-suite lifecycle hooks:
|
||||
|
||||
**Implementation:**
|
||||
- Extended `ITestSuite` interface with `beforeAll` and `afterAll` properties
|
||||
- Added `tap.beforeAll()` and `tap.afterAll()` methods
|
||||
- Integrated into `_runSuite()` execution flow
|
||||
- Properly handles nested suites
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
tap.describe('Database Tests', () => {
|
||||
tap.beforeAll(async () => {
|
||||
await initializeDatabaseConnection(); // Runs once
|
||||
});
|
||||
|
||||
tap.test('test 1', async () => {});
|
||||
tap.test('test 2', async () => {});
|
||||
|
||||
tap.afterAll(async () => {
|
||||
await closeDatabaseConnection(); // Runs once
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Execution Order per Suite:**
|
||||
1. Suite beforeAll ← NEW
|
||||
2. Suite beforeEach
|
||||
3. Test
|
||||
4. Suite afterEach
|
||||
5. (Repeat 2-4 for each test)
|
||||
6. Child suites (recursive)
|
||||
7. Suite afterAll ← NEW
|
||||
|
||||
#### 3. tap.parallel() Fluent Entry Point (COMPLETED)
|
||||
|
||||
Added fluent API for parallel test creation:
|
||||
|
||||
**Implementation:**
|
||||
- Updated `TestBuilder` class with `_parallel` flag
|
||||
- Builder constructor accepts optional parallel parameter
|
||||
- Added `tap.parallel()` method returning configured builder
|
||||
- Fixed `testParallel()` to return TapTest<T> (was void)
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
// Simple parallel test
|
||||
tap.parallel().test('fetch data', async () => {});
|
||||
|
||||
// With full configuration
|
||||
tap
|
||||
.parallel()
|
||||
.tags('api', 'integration')
|
||||
.retry(2)
|
||||
.timeout(5000)
|
||||
.test('configured parallel test', async () => {});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Consistent with other fluent builders (tags, priority, etc.)
|
||||
- More discoverable than separate `testParallel()` method
|
||||
- Allows chaining parallel with other configurations
|
||||
- `testParallel()` kept for backward compatibility
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
**tapbundle/readme.md:**
|
||||
- Added suite-level beforeAll/afterAll documentation
|
||||
- Documented postTask with execution order notes
|
||||
- Added parallel() fluent API examples
|
||||
- Expanded TapTools documentation with all methods
|
||||
- Added "Additional Tap Methods" section for fail(), getSettings(), etc.
|
||||
- Documented all previously undocumented methods
|
||||
|
||||
### Tests
|
||||
|
||||
**test/tapbundle/test.new-lifecycle.ts:**
|
||||
- Tests postTask execution order
|
||||
- Verifies suite-level beforeAll/afterAll
|
||||
- Tests nested suite lifecycle
|
||||
- Validates parallel() fluent API
|
||||
- Confirms all execution order requirements
|
||||
|
||||
**Test Results:** All 9 tests passing ✅
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None - all changes are additive and backward compatible.
|
||||
|
||||
### Migration Guide
|
||||
|
||||
No migration needed. New features are opt-in:
|
||||
- Continue using existing patterns
|
||||
- Adopt new features incrementally
|
||||
- `testParallel()` still works (recommended: switch to `parallel().test()`)
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### tap.skip.test(), tap.todo(), and tap.only.test() (Fixed)
|
||||
@@ -320,4 +445,49 @@ 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.
|
||||
This ensures timing metadata is correctly extracted and displayed in test results.
|
||||
|
||||
## Streaming Console Output (Fixed)
|
||||
|
||||
### Problem
|
||||
When tests use `process.stdout.write()` for streaming output (without newlines), each write was appearing on a separate line. This happened because:
|
||||
1. Child process stdout data events arrive as separate chunks
|
||||
2. `TapParser._processLog()` split on `\n` and processed each segment
|
||||
3. `testConsoleOutput()` used `console.log()` which added a newline to each call
|
||||
|
||||
### Solution
|
||||
The streaming behavior is now preserved by:
|
||||
1. **Line buffering for TAP parsing**: Only buffer content that looks like TAP protocol messages
|
||||
2. **True streaming for console output**: Use `process.stdout.write()` instead of `console.log()` for partial lines
|
||||
3. **Intelligent detection**: `_looksLikeTapStart()` checks if content could be a TAP protocol message
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**TapParser changes:**
|
||||
- Added `lineBuffer` property to buffer incomplete TAP protocol lines
|
||||
- Rewrote `_processLog()` to handle streaming correctly:
|
||||
- Complete lines (with newline) are processed through protocol parser
|
||||
- Incomplete lines that look like TAP are buffered
|
||||
- Incomplete lines that don't look like TAP are streamed immediately
|
||||
- Added `_looksLikeTapStart()` helper to detect TAP protocol patterns
|
||||
- Added `_handleConsoleOutput()` to handle console output with proper streaming
|
||||
- Buffer is flushed on process exit
|
||||
|
||||
**TsTestLogger changes:**
|
||||
- Added `testConsoleOutputStreaming()` method that uses `process.stdout.write()` in verbose mode
|
||||
- Added `logToTestFileRaw()` for writing to log files without adding newlines
|
||||
- In non-verbose mode, streaming content is appended to the last buffered entry
|
||||
|
||||
**TapTestResult changes:**
|
||||
- Added `addLogLineRaw()` method that doesn't append newlines
|
||||
|
||||
### Usage
|
||||
Tests can now use streaming output naturally:
|
||||
```typescript
|
||||
process.stdout.write("Loading");
|
||||
process.stdout.write(".");
|
||||
process.stdout.write(".");
|
||||
process.stdout.write(".\n");
|
||||
```
|
||||
|
||||
This will correctly display as `Loading...` on a single line in verbose mode.
|
||||
91
test/decorator.all.ts
Normal file
91
test/decorator.all.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { tap, expect } from '../ts_tapbundle/index.js';
|
||||
|
||||
/**
|
||||
* Simple class decorator for testing decorator support across runtimes
|
||||
*/
|
||||
function testDecorator(target: any) {
|
||||
target.decoratorApplied = true;
|
||||
target.decoratorData = 'Decorator was applied successfully';
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method decorator for testing
|
||||
*/
|
||||
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const result = originalMethod.apply(this, args);
|
||||
return `[logged] ${result}`;
|
||||
};
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter decorator for testing
|
||||
*/
|
||||
function validateParam(target: any, propertyKey: string, parameterIndex: number) {
|
||||
// Mark that parameter validation decorator was applied
|
||||
if (!target.decoratedParams) {
|
||||
target.decoratedParams = {};
|
||||
}
|
||||
if (!target.decoratedParams[propertyKey]) {
|
||||
target.decoratedParams[propertyKey] = [];
|
||||
}
|
||||
target.decoratedParams[propertyKey].push(parameterIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test class with decorators
|
||||
*/
|
||||
@testDecorator
|
||||
class TestClass {
|
||||
public name: string = 'test';
|
||||
|
||||
@logMethod
|
||||
public greet(message: string): string {
|
||||
return `Hello, ${message}!`;
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return 42;
|
||||
}
|
||||
|
||||
public testParams(@validateParam value: string): string {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
tap.test('Class decorator should be applied', async () => {
|
||||
expect((TestClass as any).decoratorApplied).toEqual(true);
|
||||
expect((TestClass as any).decoratorData).toEqual('Decorator was applied successfully');
|
||||
});
|
||||
|
||||
tap.test('Method decorator should modify method behavior', async () => {
|
||||
const instance = new TestClass();
|
||||
const result = instance.greet('World');
|
||||
expect(result).toEqual('[logged] Hello, World!');
|
||||
});
|
||||
|
||||
tap.test('Regular methods should work normally', async () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.getValue()).toEqual(42);
|
||||
expect(instance.name).toEqual('test');
|
||||
});
|
||||
|
||||
tap.test('Parameter decorator should be applied', async () => {
|
||||
const decoratedParams = (TestClass.prototype as any).decoratedParams;
|
||||
expect(decoratedParams).toBeDefined();
|
||||
expect(decoratedParams.testParams).toBeDefined();
|
||||
expect(decoratedParams.testParams).toContain(0);
|
||||
});
|
||||
|
||||
tap.test('Decorator metadata preservation', async () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance instanceof TestClass).toEqual(true);
|
||||
expect(instance.constructor.name).toEqual('TestClass');
|
||||
expect(instance.testParams('hello')).toEqual('hello');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
55
test/tapbundle/test.network-tools.node.ts
Normal file
55
test/tapbundle/test.network-tools.node.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
import { tapNodeTools } from '../../ts_tapbundle_serverside/index.js';
|
||||
|
||||
tap.test('should find a single free port', async () => {
|
||||
const port = await tapNodeTools.findFreePort();
|
||||
expect(port).toBeTypeOf('number');
|
||||
expect(port).toBeGreaterThanOrEqual(3000);
|
||||
expect(port).toBeLessThanOrEqual(60000);
|
||||
});
|
||||
|
||||
tap.test('should find a free port in a specific range', async () => {
|
||||
const port = await tapNodeTools.findFreePort({
|
||||
startPort: 8000,
|
||||
endPort: 9000,
|
||||
});
|
||||
expect(port).toBeGreaterThanOrEqual(8000);
|
||||
expect(port).toBeLessThanOrEqual(9000);
|
||||
});
|
||||
|
||||
tap.test('should find a free port with exclusions', async () => {
|
||||
const port = await tapNodeTools.findFreePort({
|
||||
startPort: 10000,
|
||||
endPort: 10100,
|
||||
exclude: [10000, 10001, 10002],
|
||||
});
|
||||
expect(port).toBeGreaterThanOrEqual(10000);
|
||||
expect(port).toBeLessThanOrEqual(10100);
|
||||
expect(port).not.toEqual(10000);
|
||||
expect(port).not.toEqual(10001);
|
||||
expect(port).not.toEqual(10002);
|
||||
});
|
||||
|
||||
tap.test('should find multiple free ports', async () => {
|
||||
const ports = await tapNodeTools.findFreePorts(3);
|
||||
expect(ports).toHaveLength(3);
|
||||
// All ports should be distinct
|
||||
const uniquePorts = new Set(ports);
|
||||
expect(uniquePorts.size).toEqual(3);
|
||||
for (const port of ports) {
|
||||
expect(port).toBeGreaterThanOrEqual(3000);
|
||||
expect(port).toBeLessThanOrEqual(60000);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should find a consecutive port range', async () => {
|
||||
const ports = await tapNodeTools.findFreePortRange(3, {
|
||||
startPort: 20000,
|
||||
endPort: 30000,
|
||||
});
|
||||
expect(ports).toHaveLength(3);
|
||||
expect(ports[1]).toEqual(ports[0] + 1);
|
||||
expect(ports[2]).toEqual(ports[0] + 2);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
170
test/tapbundle/test.new-lifecycle.ts
Normal file
170
test/tapbundle/test.new-lifecycle.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
|
||||
// Global state for testing new lifecycle features
|
||||
const executionOrder: string[] = [];
|
||||
let postTaskRan = false;
|
||||
|
||||
// Test preTask and postTask
|
||||
tap.preTask('setup environment', async () => {
|
||||
executionOrder.push('preTask');
|
||||
console.log('🔧 PreTask: Setting up environment');
|
||||
});
|
||||
|
||||
tap.postTask('cleanup environment', async () => {
|
||||
postTaskRan = true;
|
||||
executionOrder.push('postTask');
|
||||
console.log('🧹 PostTask: Cleaning up environment');
|
||||
});
|
||||
|
||||
// Test suite-level beforeAll and afterAll
|
||||
tap.describe('Suite with beforeAll/afterAll', () => {
|
||||
tap.beforeAll(async () => {
|
||||
executionOrder.push('suite-beforeAll');
|
||||
console.log('🔰 Suite beforeAll executed');
|
||||
});
|
||||
|
||||
tap.afterAll(async () => {
|
||||
executionOrder.push('suite-afterAll');
|
||||
console.log('🏁 Suite afterAll executed');
|
||||
});
|
||||
|
||||
tap.beforeEach(async () => {
|
||||
executionOrder.push('suite-beforeEach');
|
||||
});
|
||||
|
||||
tap.afterEach(async () => {
|
||||
executionOrder.push('suite-afterEach');
|
||||
});
|
||||
|
||||
tap.test('first test in suite', async () => {
|
||||
executionOrder.push('test-1');
|
||||
expect(executionOrder).toContain('preTask');
|
||||
expect(executionOrder).toContain('suite-beforeAll');
|
||||
console.log('✓ Test 1 executed');
|
||||
});
|
||||
|
||||
tap.test('second test in suite', async () => {
|
||||
executionOrder.push('test-2');
|
||||
expect(executionOrder).toContain('suite-beforeAll');
|
||||
console.log('✓ Test 2 executed');
|
||||
});
|
||||
});
|
||||
|
||||
// Test nested suites with beforeAll/afterAll
|
||||
tap.describe('Parent Suite', () => {
|
||||
tap.beforeAll(async () => {
|
||||
executionOrder.push('parent-beforeAll');
|
||||
console.log('🔰 Parent beforeAll executed');
|
||||
});
|
||||
|
||||
tap.afterAll(async () => {
|
||||
executionOrder.push('parent-afterAll');
|
||||
console.log('🏁 Parent afterAll executed');
|
||||
});
|
||||
|
||||
tap.test('test in parent', async () => {
|
||||
executionOrder.push('parent-test');
|
||||
expect(executionOrder).toContain('parent-beforeAll');
|
||||
});
|
||||
|
||||
tap.describe('Child Suite', () => {
|
||||
tap.beforeAll(async () => {
|
||||
executionOrder.push('child-beforeAll');
|
||||
console.log('🔰 Child beforeAll executed');
|
||||
});
|
||||
|
||||
tap.afterAll(async () => {
|
||||
executionOrder.push('child-afterAll');
|
||||
console.log('🏁 Child afterAll executed');
|
||||
});
|
||||
|
||||
tap.test('test in child', async () => {
|
||||
executionOrder.push('child-test');
|
||||
expect(executionOrder).toContain('parent-beforeAll');
|
||||
expect(executionOrder).toContain('child-beforeAll');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test parallel() fluent API
|
||||
tap.parallel().test('parallel test 1', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
executionOrder.push('parallel-1');
|
||||
console.log('⚡ Parallel test 1 executed');
|
||||
expect(true).toBeTrue();
|
||||
});
|
||||
|
||||
tap.parallel().test('parallel test 2', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
executionOrder.push('parallel-2');
|
||||
console.log('⚡ Parallel test 2 executed');
|
||||
expect(true).toBeTrue();
|
||||
});
|
||||
|
||||
// Test parallel() with configuration
|
||||
tap
|
||||
.parallel()
|
||||
.tags('integration', 'parallel')
|
||||
.timeout(1000)
|
||||
.test('configured parallel test', async () => {
|
||||
executionOrder.push('parallel-configured');
|
||||
console.log('⚡ Configured parallel test executed');
|
||||
expect(true).toBeTrue();
|
||||
});
|
||||
|
||||
// Verify execution order
|
||||
tap.test('verify lifecycle execution order', async () => {
|
||||
// Give a moment for any async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log('📊 Execution order:', executionOrder);
|
||||
|
||||
// Verify preTask ran first
|
||||
expect(executionOrder[0]).toEqual('preTask');
|
||||
|
||||
// Verify suite beforeAll ran before tests
|
||||
const suiteBeforeAllIndex = executionOrder.indexOf('suite-beforeAll');
|
||||
const test1Index = executionOrder.indexOf('test-1');
|
||||
expect(suiteBeforeAllIndex).toBeLessThan(test1Index);
|
||||
|
||||
// Verify beforeEach ran before each test
|
||||
const beforeEachIndices = executionOrder
|
||||
.map((item, index) => item === 'suite-beforeEach' ? index : -1)
|
||||
.filter(index => index !== -1);
|
||||
expect(beforeEachIndices.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify afterEach ran after each test
|
||||
const afterEachIndices = executionOrder
|
||||
.map((item, index) => item === 'suite-afterEach' ? index : -1)
|
||||
.filter(index => index !== -1);
|
||||
expect(afterEachIndices.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify afterAll ran after all tests
|
||||
const suiteAfterAllIndex = executionOrder.indexOf('suite-afterAll');
|
||||
const test2Index = executionOrder.indexOf('test-2');
|
||||
expect(suiteAfterAllIndex).toBeGreaterThan(test2Index);
|
||||
|
||||
// Verify nested suite lifecycle
|
||||
expect(executionOrder).toContain('parent-beforeAll');
|
||||
expect(executionOrder).toContain('parent-test');
|
||||
expect(executionOrder).toContain('child-beforeAll');
|
||||
expect(executionOrder).toContain('child-test');
|
||||
expect(executionOrder).toContain('child-afterAll');
|
||||
expect(executionOrder).toContain('parent-afterAll');
|
||||
|
||||
// Verify parallel tests ran
|
||||
expect(executionOrder).toContain('parallel-1');
|
||||
expect(executionOrder).toContain('parallel-2');
|
||||
expect(executionOrder).toContain('parallel-configured');
|
||||
|
||||
console.log('✅ All lifecycle hooks executed in correct order');
|
||||
});
|
||||
|
||||
// This test will verify postTask ran (after tap.start() completes)
|
||||
tap.test('verify postTask execution', async () => {
|
||||
// PostTask hasn't run yet because tests are still running
|
||||
expect(postTaskRan).toBeFalse();
|
||||
console.log('✓ Verified postTask will run after all tests');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
|
||||
import { tapNodeTools } from '../../ts_tapbundle_node/index.js';
|
||||
import { tapNodeTools } from '../../ts_tapbundle_serverside/index.js';
|
||||
|
||||
tap.test('should execure a command', async () => {
|
||||
const result = await tapNodeTools.runCommand('ls -la');
|
||||
@@ -20,9 +20,9 @@ tap.test('should create a smartmongo instance', async () => {
|
||||
await smartmongo.stop();
|
||||
});
|
||||
|
||||
tap.test('should create a smarts3 instance', async () => {
|
||||
const smarts3 = await tapNodeTools.createSmarts3();
|
||||
await smarts3.stop();
|
||||
tap.test('should create a smartstorage instance', async () => {
|
||||
const smartstorage = await tapNodeTools.createSmartStorage();
|
||||
await smartstorage.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
|
||||
@@ -47,6 +47,7 @@ tap.test('metric test fast 3 - minimal work', async () => {
|
||||
|
||||
// Test to verify that failed tests still contribute to timing metrics
|
||||
tap.test('metric test that fails - 60ms before failure', async (tools) => {
|
||||
tools.allowFailure();
|
||||
await tools.delayFor(60);
|
||||
expect(true).toBeFalse(); // This will fail
|
||||
});
|
||||
|
||||
153
test/test.directives.node.ts
Normal file
153
test/test.directives.node.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { expect, tap } from '../ts_tapbundle/index.js';
|
||||
import {
|
||||
parseDirectivesFromContent,
|
||||
mergeDirectives,
|
||||
directivesToRuntimeOptions,
|
||||
hasDirectives,
|
||||
} from '../ts/tstest.classes.testfile.directives.js';
|
||||
|
||||
tap.test('parseDirectivesFromContent - deno allowAll', async () => {
|
||||
const content = `// tstest:deno:allowAll
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
expect(directives.deno[0].key).toEqual('allowAll');
|
||||
expect(directives.deno[0].scope).toEqual('deno');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - multiple deno directives', async () => {
|
||||
const content = `// tstest:deno:allowRun
|
||||
// tstest:deno:allowFfi
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(2);
|
||||
expect(directives.deno[0].key).toEqual('allowRun');
|
||||
expect(directives.deno[1].key).toEqual('allowFfi');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - flag directive with value', async () => {
|
||||
const content = `// tstest:deno:flag:--unstable-ffi
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
expect(directives.deno[0].key).toEqual('flag');
|
||||
expect(directives.deno[0].value).toEqual('--unstable-ffi');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - node flag directive', async () => {
|
||||
const content = `// tstest:node:flag:--max-old-space-size=4096
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.node.length).toEqual(1);
|
||||
expect(directives.node[0].key).toEqual('flag');
|
||||
expect(directives.node[0].value).toEqual('--max-old-space-size=4096');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - empty lines before directives', async () => {
|
||||
const content = `
|
||||
// tstest:deno:allowAll
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
expect(directives.deno[0].key).toEqual('allowAll');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - stops at first non-comment line', async () => {
|
||||
const content = `// tstest:deno:allowRun
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
// tstest:deno:allowFfi
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
// Should only find allowRun, not allowFfi (after import)
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
expect(directives.deno[0].key).toEqual('allowRun');
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - no directives returns empty', async () => {
|
||||
const content = `import { tap } from '../tapbundle/index.js';
|
||||
tap.test('foo', async () => {});
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(hasDirectives(directives)).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - regular comments are skipped', async () => {
|
||||
const content = `// This is a regular comment
|
||||
// tstest:deno:allowAll
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('parseDirectivesFromContent - mixed runtime directives', async () => {
|
||||
const content = `// tstest:deno:allowRun
|
||||
// tstest:bun:flag:--smol
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
expect(directives.deno.length).toEqual(1);
|
||||
expect(directives.bun.length).toEqual(1);
|
||||
expect(directives.bun[0].key).toEqual('flag');
|
||||
expect(directives.bun[0].value).toEqual('--smol');
|
||||
});
|
||||
|
||||
tap.test('directivesToRuntimeOptions - deno allowAll', async () => {
|
||||
const content = `// tstest:deno:allowAll
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
const options = directivesToRuntimeOptions(directives, 'deno') as any;
|
||||
expect(options).toBeTruthy();
|
||||
expect(options.permissions).toContain('--allow-all');
|
||||
expect(options.permissions).not.toContain('--allow-read');
|
||||
});
|
||||
|
||||
tap.test('directivesToRuntimeOptions - deno extra permissions', async () => {
|
||||
const content = `// tstest:deno:allowRun
|
||||
// tstest:deno:allowFfi
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
const options = directivesToRuntimeOptions(directives, 'deno') as any;
|
||||
expect(options).toBeTruthy();
|
||||
expect(options.permissions).toContain('--allow-run');
|
||||
expect(options.permissions).toContain('--allow-ffi');
|
||||
// Should still contain defaults
|
||||
expect(options.permissions).toContain('--allow-read');
|
||||
expect(options.permissions).toContain('--allow-env');
|
||||
});
|
||||
|
||||
tap.test('directivesToRuntimeOptions - no directives returns undefined', async () => {
|
||||
const directives = parseDirectivesFromContent('import { tap } from "tapbundle";');
|
||||
const options = directivesToRuntimeOptions(directives, 'deno');
|
||||
expect(options).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('directivesToRuntimeOptions - node flag directive', async () => {
|
||||
const content = `// tstest:node:flag:--max-old-space-size=4096
|
||||
import { tap } from '../tapbundle/index.js';
|
||||
`;
|
||||
const directives = parseDirectivesFromContent(content);
|
||||
const options = directivesToRuntimeOptions(directives, 'node');
|
||||
expect(options).toBeTruthy();
|
||||
expect(options.extraArgs).toContain('--max-old-space-size=4096');
|
||||
});
|
||||
|
||||
tap.test('mergeDirectives - combines directives from init and test file', async () => {
|
||||
const init = parseDirectivesFromContent(`// tstest:deno:allowRun
|
||||
`);
|
||||
const testFile = parseDirectivesFromContent(`// tstest:deno:allowFfi
|
||||
`);
|
||||
const merged = mergeDirectives(init, testFile);
|
||||
expect(merged.deno.length).toEqual(2);
|
||||
expect(merged.deno[0].key).toEqual('allowRun');
|
||||
expect(merged.deno[1].key).toEqual('allowFfi');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
9
test/test.example.latest.docker.sh
Executable file
9
test/test.example.latest.docker.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Sample Docker test file
|
||||
# This file demonstrates the naming pattern: test.{baseName}.{variant}.docker.sh
|
||||
# The variant "latest" maps to the Dockerfile in the project root
|
||||
|
||||
echo "TAP version 13"
|
||||
echo "1..2"
|
||||
echo "ok 1 - Sample Docker test passes"
|
||||
echo "ok 2 - Docker environment is working"
|
||||
@@ -37,10 +37,11 @@ tap.test('Migration - generateReport works', async () => {
|
||||
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);
|
||||
try { await plugins.smartfsInstance.directory(tempDir).recursive().delete(); } catch (e) { /* may not exist */ }
|
||||
await plugins.smartfsInstance.directory(tempDir).recursive().create();
|
||||
|
||||
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
|
||||
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
|
||||
await plugins.smartfsInstance.file(legacyFile).write('// Legacy test file\nexport default Promise.resolve();');
|
||||
|
||||
const migration = new Migration({
|
||||
baseDir: tempDir,
|
||||
@@ -53,18 +54,19 @@ tap.test('Migration - detects legacy files when they exist', async () => {
|
||||
expect(legacyFiles[0]).toContain('test.browser.ts');
|
||||
|
||||
// Clean up
|
||||
await plugins.smartfile.fs.removeSync(tempDir);
|
||||
plugins.fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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);
|
||||
try { await plugins.smartfsInstance.directory(tempDir).recursive().delete(); } catch (e) { /* may not exist */ }
|
||||
await plugins.smartfsInstance.directory(tempDir).recursive().create();
|
||||
|
||||
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);
|
||||
await plugins.smartfsInstance.file(browserFile).write('// Browser test\nexport default Promise.resolve();');
|
||||
await plugins.smartfsInstance.file(bothFile).write('// Both test\nexport default Promise.resolve();');
|
||||
|
||||
const migration = new Migration({
|
||||
baseDir: tempDir,
|
||||
@@ -76,16 +78,17 @@ tap.test('Migration - detects both legacy pattern', async () => {
|
||||
expect(legacyFiles.length).toEqual(2);
|
||||
|
||||
// Clean up
|
||||
await plugins.smartfile.fs.removeSync(tempDir);
|
||||
plugins.fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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);
|
||||
try { await plugins.smartfsInstance.directory(tempDir).recursive().delete(); } catch (e) { /* may not exist */ }
|
||||
await plugins.smartfsInstance.directory(tempDir).recursive().create();
|
||||
|
||||
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
|
||||
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
|
||||
await plugins.smartfsInstance.file(legacyFile).write('// Legacy test file\nexport default Promise.resolve();');
|
||||
|
||||
const migration = new Migration({
|
||||
baseDir: tempDir,
|
||||
@@ -101,11 +104,11 @@ tap.test('Migration - dry run does not modify files', async () => {
|
||||
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);
|
||||
const fileExists = await plugins.smartfsInstance.file(legacyFile).exists();
|
||||
expect(fileExists).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
await plugins.smartfile.fs.removeSync(tempDir);
|
||||
plugins.fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, tap } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('This test should fail', async () => {
|
||||
tap.test('This test should fail', async (tools) => {
|
||||
tools.allowFailure();
|
||||
console.log('This test will fail on purpose');
|
||||
expect(true).toBeFalse();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, tap } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('Test that will fail with console logs', async () => {
|
||||
tap.test('Test that will fail with console logs', async (tools) => {
|
||||
tools.allowFailure();
|
||||
console.log('Starting the test...');
|
||||
console.log('Doing some setup work');
|
||||
console.log('About to check assertion');
|
||||
|
||||
|
||||
const value = 42;
|
||||
console.log(`The value is: ${value}`);
|
||||
|
||||
|
||||
// This will fail
|
||||
expect(value).toEqual(100);
|
||||
|
||||
|
||||
console.log('This log will not be reached');
|
||||
});
|
||||
|
||||
|
||||
12
test/tstest/test.gap-debug.ts
Normal file
12
test/tstest/test.gap-debug.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('check for gaps in streaming', async () => {
|
||||
// This should print "ABCD" with no gaps
|
||||
process.stdout.write("A");
|
||||
process.stdout.write("B");
|
||||
process.stdout.write("C");
|
||||
process.stdout.write("D\n");
|
||||
expect(true).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
14
test/tstest/test.gap-debug2.ts
Normal file
14
test/tstest/test.gap-debug2.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { tap, expect } from '../../ts_tapbundle/index.js';
|
||||
|
||||
tap.test('streaming with delays', async (tools) => {
|
||||
// Simulate real streaming with delays
|
||||
process.stdout.write("Progress: [");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await tools.delayFor(50);
|
||||
process.stdout.write("=");
|
||||
}
|
||||
process.stdout.write("]\n");
|
||||
expect(true).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tstest',
|
||||
version: '2.6.1',
|
||||
description: 'a test utility to run tests that match test/**/*.ts'
|
||||
version: '3.5.0',
|
||||
description: 'A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.'
|
||||
}
|
||||
|
||||
86
ts/tstest.classes.beforescripts.ts
Normal file
86
ts/tstest.classes.beforescripts.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as plugins from './tstest.plugins.js';
|
||||
import type { TsTestLogger } from './tstest.logging.js';
|
||||
import type { Smartshell } from '@push.rocks/smartshell';
|
||||
|
||||
export interface IBeforeScripts {
|
||||
/** The "test:before" or "test:before:once" script command, or null if not defined */
|
||||
beforeOnce: string | null;
|
||||
/** The "test:before:testfile" script command, or null if not defined */
|
||||
beforeTestfile: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load before-script commands from the project's package.json scripts section.
|
||||
*/
|
||||
export function loadBeforeScripts(cwd: string): IBeforeScripts {
|
||||
const result: IBeforeScripts = { beforeOnce: null, beforeTestfile: null };
|
||||
|
||||
try {
|
||||
const packageJsonPath = plugins.path.join(cwd, 'package.json');
|
||||
const packageJson = JSON.parse(plugins.fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const scripts = packageJson?.scripts;
|
||||
if (!scripts) return result;
|
||||
|
||||
// test:before takes precedence over test:before:once (they are aliases)
|
||||
if (scripts['test:before']) {
|
||||
result.beforeOnce = scripts['test:before'];
|
||||
if (scripts['test:before:once']) {
|
||||
console.warn('Warning: Both "test:before" and "test:before:once" are defined. Using "test:before".');
|
||||
}
|
||||
} else if (scripts['test:before:once']) {
|
||||
result.beforeOnce = scripts['test:before:once'];
|
||||
}
|
||||
|
||||
if (scripts['test:before:testfile']) {
|
||||
result.beforeTestfile = scripts['test:before:testfile'];
|
||||
}
|
||||
} catch {
|
||||
// No package.json or parse error — return defaults
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a before-script command and return whether it succeeded.
|
||||
*/
|
||||
export async function runBeforeScript(
|
||||
smartshellInstance: Smartshell,
|
||||
command: string,
|
||||
label: string,
|
||||
logger: TsTestLogger,
|
||||
env?: { TSTEST_FILE?: string; TSTEST_RUNTIME?: string },
|
||||
): Promise<boolean> {
|
||||
logger.beforeScriptStart(label, command);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Set environment variables if provided
|
||||
const envKeysSet: string[] = [];
|
||||
if (env) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value;
|
||||
envKeysSet.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const execResult = await smartshellInstance.execStreaming(command);
|
||||
const result = await execResult.finalPromise;
|
||||
const durationMs = Date.now() - startTime;
|
||||
const success = result.exitCode === 0;
|
||||
|
||||
logger.beforeScriptEnd(label, success, durationMs);
|
||||
return success;
|
||||
} catch {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.beforeScriptEnd(label, false, durationMs);
|
||||
return false;
|
||||
} finally {
|
||||
// Clean up environment variables
|
||||
for (const key of envKeysSet) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,10 +111,7 @@ export class Migration {
|
||||
* 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 files = plugins.fs.globSync(this.options.pattern, { cwd: this.options.baseDir }) as string[];
|
||||
|
||||
const legacyFiles: string[] = [];
|
||||
|
||||
@@ -154,7 +151,7 @@ export class Migration {
|
||||
const newPath = plugins.path.join(dirName, newFileName);
|
||||
|
||||
// Check if target file already exists
|
||||
if (await plugins.smartfile.fs.fileExists(newPath)) {
|
||||
if (await plugins.smartfsInstance.file(newPath).exists()) {
|
||||
return {
|
||||
oldPath: filePath,
|
||||
newPath,
|
||||
@@ -206,7 +203,7 @@ export class Migration {
|
||||
private async isGitRepository(dir: string): Promise<boolean> {
|
||||
try {
|
||||
const gitDir = plugins.path.join(dir, '.git');
|
||||
return await plugins.smartfile.fs.isDirectory(gitDir);
|
||||
return await plugins.smartfsInstance.directory(gitDir).exists();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ export class BunRuntimeAdapter extends RuntimeAdapter {
|
||||
|
||||
const args: string[] = ['run'];
|
||||
|
||||
// Note: Bun automatically discovers bunfig.toml in the current directory
|
||||
// This ensures TypeScript decorator support is enabled if bunfig.toml is present
|
||||
|
||||
// Add extra args
|
||||
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
|
||||
args.push(...mergedOptions.extraArgs);
|
||||
@@ -118,7 +121,7 @@ export class BunRuntimeAdapter extends RuntimeAdapter {
|
||||
// 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);
|
||||
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
||||
|
||||
let runCommand = fullCommand;
|
||||
let loaderPath: string | null = null;
|
||||
@@ -132,7 +135,7 @@ 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);
|
||||
await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
|
||||
|
||||
// Rebuild command with loader file
|
||||
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
|
||||
@@ -145,8 +148,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
if (loaderPath) {
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||
plugins.smartfile.fs.removeSync(loaderPath);
|
||||
if (plugins.fs.existsSync(loaderPath)) {
|
||||
plugins.fs.rmSync(loaderPath, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
|
||||
@@ -107,7 +107,8 @@ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
|
||||
const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
|
||||
|
||||
// lets bundle the test
|
||||
await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
|
||||
try { await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().delete(); } catch (e) { /* may not exist */ }
|
||||
await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().create();
|
||||
await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
@@ -115,30 +116,28 @@ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
|
||||
// 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,
|
||||
// Use SmartServe with setHandler() to bypass global ControllerRegistry
|
||||
const fileServer = new plugins.smartserve.FileServer({ root: tsbundleCacheDirPath });
|
||||
const server = new plugins.smartserve.SmartServe({ port: httpPort });
|
||||
server.setHandler(async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/test') {
|
||||
return new Response(`
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
globalThis.testdom = true;
|
||||
globalThis.wsPort = ${wsPort};
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
const staticResponse = await fileServer.serve(request);
|
||||
if (staticResponse) return staticResponse;
|
||||
return new Response('Not Found', { status: 404 });
|
||||
});
|
||||
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
|
||||
|
||||
@@ -10,6 +10,20 @@ import { TapParser } from './tstest.classes.tap.parser.js';
|
||||
import { TsTestLogger } from './tstest.logging.js';
|
||||
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||
|
||||
/**
|
||||
* Default Deno permissions used when no directives override them.
|
||||
*/
|
||||
export const DENO_DEFAULT_PERMISSIONS = [
|
||||
'--allow-read',
|
||||
'--allow-env',
|
||||
'--allow-net',
|
||||
'--allow-write',
|
||||
'--allow-sys',
|
||||
'--allow-import',
|
||||
'--node-modules-dir',
|
||||
'--sloppy-imports',
|
||||
];
|
||||
|
||||
/**
|
||||
* Deno runtime adapter
|
||||
* Executes tests using the Deno runtime
|
||||
@@ -31,18 +45,21 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
|
||||
* Get default Deno options
|
||||
*/
|
||||
protected getDefaultOptions(): DenoOptions {
|
||||
// Auto-detect deno.json or deno.jsonc config file for TypeScript decorator support
|
||||
let configPath: string | undefined;
|
||||
const denoJsonPath = plugins.path.join(process.cwd(), 'deno.json');
|
||||
const denoJsoncPath = plugins.path.join(process.cwd(), 'deno.jsonc');
|
||||
|
||||
if (plugins.fs.existsSync(denoJsonPath)) {
|
||||
configPath = denoJsonPath;
|
||||
} else if (plugins.fs.existsSync(denoJsoncPath)) {
|
||||
configPath = denoJsoncPath;
|
||||
}
|
||||
|
||||
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
|
||||
],
|
||||
configPath,
|
||||
permissions: [...DENO_DEFAULT_PERMISSIONS],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,16 +107,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
|
||||
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',
|
||||
];
|
||||
const permissions = mergedOptions.permissions || [...DENO_DEFAULT_PERMISSIONS];
|
||||
args.push(...permissions);
|
||||
|
||||
// Add config file if specified
|
||||
@@ -161,7 +169,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
|
||||
// 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);
|
||||
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
||||
|
||||
let runCommand = fullCommand;
|
||||
let loaderPath: string | null = null;
|
||||
@@ -175,21 +183,32 @@ 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);
|
||||
await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
|
||||
|
||||
// Rebuild command with loader file
|
||||
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
|
||||
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
|
||||
}
|
||||
|
||||
// Pre-resolve dependencies for the Deno test entrypoint
|
||||
const installTarget = loaderPath || testFile;
|
||||
const installArgs = ['install', '--entrypoint', installTarget];
|
||||
if (mergedOptions.configPath) {
|
||||
installArgs.push('--config', mergedOptions.configPath);
|
||||
}
|
||||
const installCommand = `deno ${installArgs.join(' ')}`;
|
||||
console.log(cs(` ⏳ Resolving Deno dependencies for ${plugins.path.basename(testFile)}...`, 'blue'));
|
||||
await this.smartshellInstance.execSilent(installCommand, { cwd: process.cwd() });
|
||||
console.log(cs(` ✓ Deno dependencies resolved`, 'green'));
|
||||
|
||||
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);
|
||||
if (plugins.fs.existsSync(loaderPath)) {
|
||||
plugins.fs.rmSync(loaderPath, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
|
||||
251
ts/tstest.classes.runtime.docker.ts
Normal file
251
ts/tstest.classes.runtime.docker.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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';
|
||||
import {
|
||||
parseDockerTestFilename,
|
||||
mapVariantToDockerfile,
|
||||
isDockerTestFile
|
||||
} from './tstest.classes.runtime.parser.js';
|
||||
|
||||
/**
|
||||
* Docker runtime adapter
|
||||
* Executes shell test files inside Docker containers
|
||||
* Pattern: test.{variant}.docker.sh
|
||||
* Variants map to Dockerfiles: latest -> Dockerfile, others -> Dockerfile_{variant}
|
||||
*/
|
||||
export class DockerRuntimeAdapter extends RuntimeAdapter {
|
||||
readonly id: Runtime = 'node'; // Using 'node' temporarily as Runtime type doesn't include 'docker'
|
||||
readonly displayName: string = 'Docker';
|
||||
|
||||
private builtImages: Set<string> = new Set(); // Track built images to avoid rebuilding
|
||||
|
||||
constructor(
|
||||
private logger: TsTestLogger,
|
||||
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
|
||||
private timeoutSeconds: number | null,
|
||||
private cwd: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker CLI is available
|
||||
*/
|
||||
async checkAvailable(): Promise<RuntimeAvailability> {
|
||||
try {
|
||||
const result = await this.smartshellInstance.exec('docker --version');
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
available: false,
|
||||
error: 'Docker command failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Extract version from output like "Docker version 24.0.5, build ced0996"
|
||||
const versionMatch = result.stdout.match(/Docker version ([^,]+)/);
|
||||
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
return {
|
||||
available: true,
|
||||
version,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: `Docker not found: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command configuration for Docker test execution
|
||||
* This is used for informational purposes
|
||||
*/
|
||||
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
|
||||
const parsed = parseDockerTestFilename(testFile);
|
||||
const dockerfilePath = mapVariantToDockerfile(parsed.variant, this.cwd);
|
||||
const imageName = `tstest-${parsed.variant}`;
|
||||
|
||||
return {
|
||||
command: 'docker',
|
||||
args: [
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${this.cwd}/test:/test`,
|
||||
imageName,
|
||||
'taprun',
|
||||
`/test/${plugins.path.basename(testFile)}`
|
||||
],
|
||||
env: {},
|
||||
cwd: this.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Docker image from the specified Dockerfile
|
||||
*/
|
||||
private async buildDockerImage(dockerfilePath: string, imageName: string): Promise<void> {
|
||||
// Check if image is already built
|
||||
if (this.builtImages.has(imageName)) {
|
||||
this.logger.tapOutput(`Using cached Docker image: ${imageName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Dockerfile exists
|
||||
if (!await plugins.smartfsInstance.file(dockerfilePath).exists()) {
|
||||
throw new Error(
|
||||
`Dockerfile not found: ${dockerfilePath}\n` +
|
||||
`Expected Dockerfile for Docker test variant.`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.tapOutput(`Building Docker image: ${imageName} from ${dockerfilePath}`);
|
||||
|
||||
try {
|
||||
const buildResult = await this.smartshellInstance.exec(
|
||||
`docker build -f ${dockerfilePath} -t ${imageName} ${this.cwd}`,
|
||||
{
|
||||
cwd: this.cwd,
|
||||
}
|
||||
);
|
||||
|
||||
if (buildResult.exitCode !== 0) {
|
||||
throw new Error(`Docker build failed:\n${buildResult.stderr}`);
|
||||
}
|
||||
|
||||
this.builtImages.add(imageName);
|
||||
this.logger.tapOutput(`✅ Docker image built successfully: ${imageName}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to build Docker image: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Docker test file
|
||||
*/
|
||||
async run(
|
||||
testFile: string,
|
||||
index: number,
|
||||
total: number,
|
||||
options?: RuntimeOptions
|
||||
): Promise<TapParser> {
|
||||
this.logger.testFileStart(testFile, this.displayName, index, total);
|
||||
|
||||
// Parse the Docker test filename
|
||||
const parsed = parseDockerTestFilename(testFile);
|
||||
const dockerfilePath = mapVariantToDockerfile(parsed.variant, this.cwd);
|
||||
const imageName = `tstest-${parsed.variant}`;
|
||||
|
||||
// Build the Docker image
|
||||
await this.buildDockerImage(dockerfilePath, imageName);
|
||||
|
||||
// Prepare the test file path relative to the mounted directory
|
||||
// We need to get the path relative to cwd
|
||||
const absoluteTestPath = plugins.path.isAbsolute(testFile)
|
||||
? testFile
|
||||
: plugins.path.join(this.cwd, testFile);
|
||||
|
||||
const relativeTestPath = plugins.path.relative(this.cwd, absoluteTestPath);
|
||||
|
||||
// Create TAP parser
|
||||
const tapParser = new TapParser(testFile + ':docker', this.logger);
|
||||
|
||||
try {
|
||||
// Build docker run command
|
||||
const dockerArgs = [
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${this.cwd}/test:/test`,
|
||||
imageName,
|
||||
'taprun',
|
||||
`/test/${plugins.path.basename(testFile)}`
|
||||
];
|
||||
|
||||
this.logger.tapOutput(`Executing: docker ${dockerArgs.join(' ')}`);
|
||||
|
||||
// Execute the Docker container
|
||||
const execPromise = this.smartshellInstance.execStreaming(
|
||||
`docker ${dockerArgs.join(' ')}`,
|
||||
{
|
||||
cwd: this.cwd,
|
||||
}
|
||||
);
|
||||
|
||||
// Set up timeout if configured
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
if (this.timeoutSeconds) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
this.logger.tapOutput(`⏱️ Test timeout (${this.timeoutSeconds}s) - killing container`);
|
||||
// Try to kill any running containers with this image
|
||||
this.smartshellInstance.exec(`docker ps -q --filter ancestor=${imageName} | xargs -r docker kill`);
|
||||
}, this.timeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
// Stream output to TAP parser line by line
|
||||
execPromise.childProcess.stdout.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
tapParser.handleTapLog(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
execPromise.childProcess.stderr.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
this.logger.tapOutput(cs(`[stderr] ${output}`, 'orange'));
|
||||
});
|
||||
|
||||
// Wait for completion
|
||||
const result = await execPromise;
|
||||
|
||||
// Clear timeout
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
this.logger.tapOutput(cs(`❌ Docker test failed with exit code ${result.exitCode}`, 'red'));
|
||||
}
|
||||
|
||||
// Evaluate final result
|
||||
await tapParser.evaluateFinalResult();
|
||||
|
||||
} catch (error) {
|
||||
this.logger.tapOutput(cs(`❌ Error running Docker test: ${error.message}`, 'red'));
|
||||
// Add a failing test result to the parser
|
||||
tapParser.handleTapLog('not ok 1 - Docker test execution failed');
|
||||
await tapParser.evaluateFinalResult();
|
||||
}
|
||||
|
||||
return tapParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up built Docker images (optional, can be called at end of test suite)
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
for (const imageName of this.builtImages) {
|
||||
try {
|
||||
this.logger.tapOutput(`Removing Docker image: ${imageName}`);
|
||||
await this.smartshellInstance.exec(`docker rmi ${imageName}`);
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
this.logger.tapOutput(cs(`Warning: Failed to remove image ${imageName}: ${error.message}`, 'orange'));
|
||||
}
|
||||
}
|
||||
this.builtImages.clear();
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export class NodeRuntimeAdapter extends RuntimeAdapter {
|
||||
// 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);
|
||||
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
||||
|
||||
// Determine which file to run
|
||||
let fileToRun = testFile;
|
||||
@@ -138,7 +138,7 @@ 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);
|
||||
await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
|
||||
fileToRun = loaderPath;
|
||||
}
|
||||
|
||||
@@ -150,8 +150,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
if (loaderPath) {
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||
plugins.smartfile.fs.removeSync(loaderPath);
|
||||
if (plugins.fs.existsSync(loaderPath)) {
|
||||
plugins.fs.rmSync(loaderPath, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface ParserConfig {
|
||||
|
||||
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 VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts', 'sh']);
|
||||
const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun'];
|
||||
|
||||
// Legacy mappings for backwards compatibility
|
||||
@@ -228,3 +228,81 @@ export function getLegacyMigrationTarget(fileName: string): string | null {
|
||||
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker test file information
|
||||
*/
|
||||
export interface DockerTestFileInfo {
|
||||
baseName: string;
|
||||
variant: string;
|
||||
isDockerTest: true;
|
||||
original: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename matches the Docker test pattern: *.{variant}.docker.sh
|
||||
* Examples: test.latest.docker.sh, test.integration.npmci.docker.sh
|
||||
*/
|
||||
export function isDockerTestFile(fileName: string): boolean {
|
||||
// Must end with .docker.sh
|
||||
if (!fileName.endsWith('.docker.sh')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract filename from path if needed
|
||||
const name = fileName.split('/').pop() || fileName;
|
||||
|
||||
// Must have at least 3 parts: [baseName, variant, docker, sh]
|
||||
const parts = name.split('.');
|
||||
return parts.length >= 4 && parts[parts.length - 2] === 'docker' && parts[parts.length - 1] === 'sh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Docker test filename to extract variant and base name
|
||||
* Pattern: test.{baseName}.{variant}.docker.sh
|
||||
* Examples:
|
||||
* - test.latest.docker.sh -> { baseName: 'test', variant: 'latest' }
|
||||
* - test.integration.npmci.docker.sh -> { baseName: 'test.integration', variant: 'npmci' }
|
||||
*/
|
||||
export function parseDockerTestFilename(filePath: string): DockerTestFileInfo {
|
||||
// Extract just the filename from the path
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const original = fileName;
|
||||
|
||||
if (!isDockerTestFile(fileName)) {
|
||||
throw new Error(`Not a valid Docker test file: "${fileName}". Expected pattern: *.{variant}.docker.sh`);
|
||||
}
|
||||
|
||||
// Remove .docker.sh suffix
|
||||
const withoutSuffix = fileName.slice(0, -10); // Remove '.docker.sh'
|
||||
const tokens = withoutSuffix.split('.');
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`Invalid Docker test file: empty basename in "${fileName}"`);
|
||||
}
|
||||
|
||||
// Last token before .docker.sh is the variant
|
||||
const variant = tokens[tokens.length - 1];
|
||||
|
||||
// Everything else is the base name
|
||||
const baseName = tokens.slice(0, -1).join('.');
|
||||
|
||||
return {
|
||||
baseName: baseName || 'test',
|
||||
variant,
|
||||
isDockerTest: true,
|
||||
original,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Docker variant to its corresponding Dockerfile path
|
||||
* "latest" -> "Dockerfile"
|
||||
* Other variants -> "Dockerfile_{variant}"
|
||||
*/
|
||||
export function mapVariantToDockerfile(variant: string, baseDir: string): string {
|
||||
if (variant === 'latest') {
|
||||
return `${baseDir}/Dockerfile`;
|
||||
}
|
||||
return `${baseDir}/Dockerfile_${variant}`;
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ export class TapParser {
|
||||
receivedTests: number = 0;
|
||||
|
||||
activeTapTestResult: TapTestResult;
|
||||
|
||||
|
||||
private logger: TsTestLogger;
|
||||
private protocolParser: ProtocolParser;
|
||||
private protocolVersion: string | null = null;
|
||||
private startTime: number;
|
||||
private lineBuffer: string = '';
|
||||
|
||||
/**
|
||||
* the constructor for TapParser
|
||||
@@ -71,42 +72,99 @@ export class TapParser {
|
||||
if (Buffer.isBuffer(logChunk)) {
|
||||
logChunk = logChunk.toString();
|
||||
}
|
||||
const logLineArray = logChunk.split('\n');
|
||||
if (logLineArray[logLineArray.length - 1] === '') {
|
||||
logLineArray.pop();
|
||||
|
||||
// Prepend any buffered content from previous incomplete line
|
||||
const fullChunk = this.lineBuffer + logChunk;
|
||||
this.lineBuffer = '';
|
||||
|
||||
// Split into segments by newline
|
||||
const segments = fullChunk.split('\n');
|
||||
const lastIndex = segments.length - 1;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
const isLastSegment = (i === lastIndex);
|
||||
const hasNewline = !isLastSegment; // All segments except last had a newline after them
|
||||
|
||||
if (hasNewline) {
|
||||
// Complete line - check if it's a TAP protocol message
|
||||
const messages = this.protocolParser.parseLine(segment);
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Handle protocol messages
|
||||
for (const message of messages) {
|
||||
this._handleProtocolMessage(message, segment);
|
||||
}
|
||||
} else {
|
||||
// Non-protocol complete line - handle as console output
|
||||
this._handleConsoleOutput(segment, true);
|
||||
}
|
||||
} else if (segment) {
|
||||
// Last segment without newline - could be:
|
||||
// 1. Partial console output (stream immediately)
|
||||
// 2. Start of a TAP message (need to buffer for protocol parsing)
|
||||
|
||||
// Check if it looks like the start of a TAP protocol message
|
||||
if (this._looksLikeTapStart(segment)) {
|
||||
// Buffer it for complete line parsing
|
||||
this.lineBuffer = segment;
|
||||
} else {
|
||||
// Stream immediately as console output (no newline)
|
||||
this._handleConsoleOutput(segment, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text could be the start of a TAP protocol message
|
||||
*/
|
||||
private _looksLikeTapStart(text: string): boolean {
|
||||
return (
|
||||
text.startsWith('ok ') ||
|
||||
text.startsWith('not ok ') ||
|
||||
text.startsWith('1..') ||
|
||||
text.startsWith('# ') ||
|
||||
text.startsWith('TAP version ') ||
|
||||
text.startsWith('⟦TSTEST:') ||
|
||||
text.startsWith('Bail out!')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle console output from test, preserving streaming behavior
|
||||
*/
|
||||
private _handleConsoleOutput(text: string, hasNewline: boolean) {
|
||||
// Check for snapshot communication (legacy)
|
||||
const snapshotMatch = text.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||
if (snapshotMatch) {
|
||||
const base64Data = snapshotMatch[1];
|
||||
try {
|
||||
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
|
||||
this.handleSnapshot(snapshotData);
|
||||
} catch (error: any) {
|
||||
if (this.logger) {
|
||||
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each line through the protocol parser
|
||||
for (const logLine of logLineArray) {
|
||||
const messages = this.protocolParser.parseLine(logLine);
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Handle protocol messages
|
||||
for (const message of messages) {
|
||||
this._handleProtocolMessage(message, logLine);
|
||||
}
|
||||
// Add to test result buffer
|
||||
if (this.activeTapTestResult) {
|
||||
if (hasNewline) {
|
||||
this.activeTapTestResult.addLogLine(text);
|
||||
} else {
|
||||
// Not a protocol message, handle as console output
|
||||
if (this.activeTapTestResult) {
|
||||
this.activeTapTestResult.addLogLine(logLine);
|
||||
}
|
||||
|
||||
// Check for snapshot communication (legacy)
|
||||
const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/);
|
||||
if (snapshotMatch) {
|
||||
const base64Data = snapshotMatch[1];
|
||||
try {
|
||||
const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString());
|
||||
this.handleSnapshot(snapshotData);
|
||||
} catch (error: any) {
|
||||
if (this.logger) {
|
||||
this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else if (this.logger) {
|
||||
// This is console output from the test file
|
||||
this.logger.testConsoleOutput(logLine);
|
||||
}
|
||||
this.activeTapTestResult.addLogLineRaw(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Output to logger with streaming support
|
||||
if (this.logger) {
|
||||
if (hasNewline) {
|
||||
this.logger.testConsoleOutput(text);
|
||||
} else {
|
||||
this.logger.testConsoleOutputStreaming(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,6 +475,11 @@ export class TapParser {
|
||||
this._processLog(data);
|
||||
});
|
||||
childProcessArg.on('exit', async () => {
|
||||
// Flush any remaining buffered content
|
||||
if (this.lineBuffer) {
|
||||
this._handleConsoleOutput(this.lineBuffer, false);
|
||||
this.lineBuffer = '';
|
||||
}
|
||||
await this.evaluateFinalResult();
|
||||
done.resolve();
|
||||
});
|
||||
@@ -424,7 +487,9 @@ export class TapParser {
|
||||
}
|
||||
|
||||
public async handleTapLog(tapLog: string) {
|
||||
this._processLog(tapLog);
|
||||
// Each WebSocket message represents a complete console.log() call,
|
||||
// so append newline to ensure proper line-by-line processing
|
||||
this._processLog(tapLog + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,12 +497,10 @@ export class TapParser {
|
||||
*/
|
||||
private async handleSnapshot(snapshotData: { path: string; content: string; action: string }) {
|
||||
try {
|
||||
const smartfile = await import('@push.rocks/smartfile');
|
||||
|
||||
if (snapshotData.action === 'compare') {
|
||||
// Try to read existing snapshot
|
||||
try {
|
||||
const existingSnapshot = await smartfile.fs.toStringSync(snapshotData.path);
|
||||
const existingSnapshot = await plugins.smartfsInstance.file(snapshotData.path).encoding('utf8').read() as string;
|
||||
if (existingSnapshot !== snapshotData.content) {
|
||||
// Snapshot mismatch
|
||||
if (this.logger) {
|
||||
@@ -455,8 +518,8 @@ export class TapParser {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Snapshot doesn't exist, create it
|
||||
const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
|
||||
await smartfile.fs.ensureDir(dirPath);
|
||||
await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
|
||||
await plugins.smartfsInstance.directory(dirPath).recursive().create();
|
||||
await plugins.smartfsInstance.file(snapshotData.path).write(snapshotData.content);
|
||||
if (this.logger) {
|
||||
this.logger.testConsoleOutput(`Snapshot created: ${snapshotData.path}`);
|
||||
}
|
||||
@@ -467,8 +530,8 @@ export class TapParser {
|
||||
} else if (snapshotData.action === 'update') {
|
||||
// Update snapshot
|
||||
const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
|
||||
await smartfile.fs.ensureDir(dirPath);
|
||||
await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
|
||||
await plugins.smartfsInstance.directory(dirPath).recursive().create();
|
||||
await plugins.smartfsInstance.file(snapshotData.path).write(snapshotData.content);
|
||||
if (this.logger) {
|
||||
this.logger.testConsoleOutput(`Snapshot updated: ${snapshotData.path}`);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export class TapTestResult {
|
||||
constructor(public id: number) {}
|
||||
|
||||
/**
|
||||
* adds a logLine to the log buffer of the test
|
||||
* adds a logLine to the log buffer of the test (with newline appended)
|
||||
* @param logLine
|
||||
*/
|
||||
addLogLine(logLine: string) {
|
||||
@@ -19,6 +19,15 @@ export class TapTestResult {
|
||||
this.testLogBuffer = Buffer.concat([this.testLogBuffer, logLineBuffer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* adds raw text to the log buffer without appending newline (for streaming output)
|
||||
* @param text
|
||||
*/
|
||||
addLogLineRaw(text: string) {
|
||||
const logLineBuffer = Buffer.from(text);
|
||||
this.testLogBuffer = Buffer.concat([this.testLogBuffer, logLineBuffer]);
|
||||
}
|
||||
|
||||
setTestResult(testOkArg: boolean) {
|
||||
this.testOk = testOkArg;
|
||||
this.testSettled = true;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as plugins from './tstest.plugins.js';
|
||||
import * as paths from './tstest.paths.js';
|
||||
import { SmartFile } from '@push.rocks/smartfile';
|
||||
import { type SmartFile, SmartFileFactory } from '@push.rocks/smartfile';
|
||||
import { TestExecutionMode } from './index.js';
|
||||
|
||||
const smartFileFactory = SmartFileFactory.nodeFs();
|
||||
|
||||
// tap related stuff
|
||||
import { TapCombinator } from './tstest.classes.tap.combinator.js';
|
||||
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||
@@ -45,28 +47,28 @@ export class TestDirectory {
|
||||
switch (this.executionMode) {
|
||||
case TestExecutionMode.FILE:
|
||||
// Single file mode
|
||||
const filePath = plugins.path.isAbsolute(this.testPath)
|
||||
? this.testPath
|
||||
const filePath = plugins.path.isAbsolute(this.testPath)
|
||||
? this.testPath
|
||||
: plugins.path.join(this.cwd, this.testPath);
|
||||
|
||||
if (await plugins.smartfile.fs.fileExists(filePath)) {
|
||||
this.testfileArray = [await plugins.smartfile.SmartFile.fromFilePath(filePath)];
|
||||
|
||||
if (await plugins.smartfsInstance.file(filePath).exists()) {
|
||||
this.testfileArray = [await smartFileFactory.fromFilePath(filePath)];
|
||||
} else {
|
||||
throw new Error(`Test file not found: ${filePath}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case TestExecutionMode.GLOB:
|
||||
// Glob pattern mode - use listFileTree which supports glob patterns
|
||||
// Glob pattern mode - use Node.js fs.globSync for full glob support
|
||||
const globPattern = this.testPath;
|
||||
const matchedFiles = await plugins.smartfile.fs.listFileTree(this.cwd, globPattern);
|
||||
|
||||
const matchedFiles = plugins.fs.globSync(globPattern, { cwd: this.cwd });
|
||||
|
||||
this.testfileArray = await Promise.all(
|
||||
matchedFiles.map(async (filePath) => {
|
||||
const absolutePath = plugins.path.isAbsolute(filePath)
|
||||
? filePath
|
||||
matchedFiles.map(async (filePath: string) => {
|
||||
const absolutePath = plugins.path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: plugins.path.join(this.cwd, filePath);
|
||||
return await plugins.smartfile.SmartFile.fromFilePath(absolutePath);
|
||||
return await smartFileFactory.fromFilePath(absolutePath);
|
||||
})
|
||||
);
|
||||
break;
|
||||
@@ -74,16 +76,21 @@ export class TestDirectory {
|
||||
case TestExecutionMode.DIRECTORY:
|
||||
// Directory mode - now recursive with ** pattern
|
||||
const dirPath = plugins.path.join(this.cwd, this.testPath);
|
||||
const testPattern = '**/test*.ts';
|
||||
|
||||
const testFiles = await plugins.smartfile.fs.listFileTree(dirPath, testPattern);
|
||||
|
||||
|
||||
// Search for both TypeScript test files and Docker shell test files
|
||||
const tsPattern = '**/test*.ts';
|
||||
const dockerPattern = '**/*.docker.sh';
|
||||
|
||||
const tsFiles = plugins.fs.globSync(tsPattern, { cwd: dirPath });
|
||||
const dockerFiles = plugins.fs.globSync(dockerPattern, { cwd: dirPath });
|
||||
const allTestFiles = [...tsFiles, ...dockerFiles] as string[];
|
||||
|
||||
this.testfileArray = await Promise.all(
|
||||
testFiles.map(async (filePath) => {
|
||||
allTestFiles.map(async (filePath) => {
|
||||
const absolutePath = plugins.path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: plugins.path.join(dirPath, filePath);
|
||||
return await plugins.smartfile.SmartFile.fromFilePath(absolutePath);
|
||||
return await smartFileFactory.fromFilePath(absolutePath);
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
226
ts/tstest.classes.testfile.directives.ts
Normal file
226
ts/tstest.classes.testfile.directives.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import * as plugins from './tstest.plugins.js';
|
||||
import type { DenoOptions, RuntimeOptions } from './tstest.classes.runtime.adapter.js';
|
||||
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||
import { DENO_DEFAULT_PERMISSIONS } from './tstest.classes.runtime.deno.js';
|
||||
|
||||
type DirectiveScope = Runtime | 'global';
|
||||
|
||||
export interface ITestFileDirective {
|
||||
scope: DirectiveScope;
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface IParsedDirectives {
|
||||
deno: ITestFileDirective[];
|
||||
node: ITestFileDirective[];
|
||||
bun: ITestFileDirective[];
|
||||
chromium: ITestFileDirective[];
|
||||
global: ITestFileDirective[];
|
||||
}
|
||||
|
||||
const VALID_SCOPES = new Set<string>(['deno', 'node', 'bun', 'chromium']);
|
||||
|
||||
const DENO_PERMISSION_MAP: Record<string, string> = {
|
||||
allowAll: '--allow-all',
|
||||
allowRun: '--allow-run',
|
||||
allowFfi: '--allow-ffi',
|
||||
allowHrtime: '--allow-hrtime',
|
||||
allowRead: '--allow-read',
|
||||
allowWrite: '--allow-write',
|
||||
allowNet: '--allow-net',
|
||||
allowEnv: '--allow-env',
|
||||
allowSys: '--allow-sys',
|
||||
};
|
||||
|
||||
function createEmptyDirectives(): IParsedDirectives {
|
||||
return { deno: [], node: [], bun: [], chromium: [], global: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tstest directives from file content.
|
||||
* Scans comments at the top of the file (before any code).
|
||||
*/
|
||||
export function parseDirectivesFromContent(content: string): IParsedDirectives {
|
||||
const result = createEmptyDirectives();
|
||||
const lines = content.split('\n');
|
||||
const maxLines = Math.min(lines.length, 30);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (line === '') continue;
|
||||
|
||||
// Stop at first non-comment line
|
||||
if (!line.startsWith('//')) break;
|
||||
|
||||
// Match tstest directive: // tstest:<rest>
|
||||
const match = line.match(/^\/\/\s*tstest:(.+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const parts = match[1].split(':');
|
||||
if (parts.length < 2) {
|
||||
console.warn(`Warning: malformed tstest directive: "${line}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopeStr = parts[0].trim();
|
||||
const key = parts[1].trim();
|
||||
const value = parts.length > 2 ? parts.slice(2).join(':').trim() : undefined;
|
||||
|
||||
// Handle global directives (env, timeout)
|
||||
if (scopeStr === 'env' || scopeStr === 'timeout') {
|
||||
result.global.push({
|
||||
scope: 'global',
|
||||
key: scopeStr,
|
||||
value: key + (value !== undefined ? ':' + value : ''),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!VALID_SCOPES.has(scopeStr)) {
|
||||
console.warn(`Warning: unknown tstest directive scope "${scopeStr}" in: "${line}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scope = scopeStr as Runtime;
|
||||
result[scope].push({ scope, key, value });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse directives from a test file on disk.
|
||||
*/
|
||||
export async function parseDirectivesFromFile(filePath: string): Promise<IParsedDirectives> {
|
||||
try {
|
||||
const content = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
return parseDirectivesFromContent(content);
|
||||
} catch {
|
||||
return createEmptyDirectives();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge directives from 00init.ts and the test file.
|
||||
* Test file directives are appended (take effect after init directives).
|
||||
*/
|
||||
export function mergeDirectives(init: IParsedDirectives, testFile: IParsedDirectives): IParsedDirectives {
|
||||
return {
|
||||
deno: [...init.deno, ...testFile.deno],
|
||||
node: [...init.node, ...testFile.node],
|
||||
bun: [...init.bun, ...testFile.bun],
|
||||
chromium: [...init.chromium, ...testFile.chromium],
|
||||
global: [...init.global, ...testFile.global],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any directives exist for any scope.
|
||||
*/
|
||||
export function hasDirectives(directives: IParsedDirectives): boolean {
|
||||
return (
|
||||
directives.deno.length > 0 ||
|
||||
directives.node.length > 0 ||
|
||||
directives.bun.length > 0 ||
|
||||
directives.chromium.length > 0 ||
|
||||
directives.global.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed directives into DenoOptions.
|
||||
*/
|
||||
function directivesToDenoOptions(directives: IParsedDirectives): DenoOptions | undefined {
|
||||
const denoDirectives = directives.deno;
|
||||
if (denoDirectives.length === 0 && directives.global.length === 0) return undefined;
|
||||
|
||||
const options: DenoOptions = {};
|
||||
const extraPermissions: string[] = [];
|
||||
const extraArgs: string[] = [];
|
||||
const env: Record<string, string> = {};
|
||||
let useAllowAll = false;
|
||||
|
||||
for (const d of denoDirectives) {
|
||||
if (d.key === 'allowAll') {
|
||||
useAllowAll = true;
|
||||
} else if (DENO_PERMISSION_MAP[d.key]) {
|
||||
extraPermissions.push(DENO_PERMISSION_MAP[d.key]);
|
||||
} else if (d.key === 'flag' && d.value) {
|
||||
extraArgs.push(d.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Process global directives
|
||||
for (const d of directives.global) {
|
||||
if (d.key === 'env' && d.value) {
|
||||
const eqIndex = d.value.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useAllowAll) {
|
||||
// --allow-all replaces individual permissions, but keep compatibility flags
|
||||
options.permissions = ['--allow-all', '--node-modules-dir', '--sloppy-imports'];
|
||||
} else if (extraPermissions.length > 0) {
|
||||
// Start with defaults and add extra permissions (deduplicated)
|
||||
const allPermissions = [...DENO_DEFAULT_PERMISSIONS];
|
||||
for (const p of extraPermissions) {
|
||||
if (!allPermissions.includes(p)) {
|
||||
allPermissions.push(p);
|
||||
}
|
||||
}
|
||||
options.permissions = allPermissions;
|
||||
}
|
||||
|
||||
if (extraArgs.length > 0) options.extraArgs = extraArgs;
|
||||
if (Object.keys(env).length > 0) options.env = env;
|
||||
|
||||
// Return undefined if nothing was set
|
||||
if (!options.permissions && !options.extraArgs && !options.env) return undefined;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed directives into RuntimeOptions for Node/Bun (flag directives only).
|
||||
*/
|
||||
function directivesToGenericOptions(directives: ITestFileDirective[], globalDirectives: ITestFileDirective[]): RuntimeOptions | undefined {
|
||||
const extraArgs: string[] = [];
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const d of directives) {
|
||||
if (d.key === 'flag' && d.value) {
|
||||
extraArgs.push(d.value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of globalDirectives) {
|
||||
if (d.key === 'env' && d.value) {
|
||||
const eqIndex = d.value.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extraArgs.length === 0 && Object.keys(env).length === 0) return undefined;
|
||||
|
||||
const options: RuntimeOptions = {};
|
||||
if (extraArgs.length > 0) options.extraArgs = extraArgs;
|
||||
if (Object.keys(env).length > 0) options.env = env;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed directives into RuntimeOptions for a specific runtime.
|
||||
*/
|
||||
export function directivesToRuntimeOptions(directives: IParsedDirectives, runtime: Runtime): RuntimeOptions | undefined {
|
||||
if (runtime === 'deno') {
|
||||
return directivesToDenoOptions(directives);
|
||||
}
|
||||
return directivesToGenericOptions(directives[runtime] || [], directives.global);
|
||||
}
|
||||
@@ -11,12 +11,24 @@ import { TsTestLogger } from './tstest.logging.js';
|
||||
import type { LogOptions } from './tstest.logging.js';
|
||||
|
||||
// Runtime adapters
|
||||
import { parseTestFilename } from './tstest.classes.runtime.parser.js';
|
||||
import { parseTestFilename, isDockerTestFile, parseDockerTestFilename } 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';
|
||||
import { DockerRuntimeAdapter } from './tstest.classes.runtime.docker.js';
|
||||
|
||||
// Test file directives
|
||||
import {
|
||||
parseDirectivesFromFile,
|
||||
mergeDirectives,
|
||||
directivesToRuntimeOptions,
|
||||
hasDirectives,
|
||||
} from './tstest.classes.testfile.directives.js';
|
||||
|
||||
// Before-script support
|
||||
import { loadBeforeScripts, runBeforeScript, type IBeforeScripts } from './tstest.classes.beforescripts.js';
|
||||
|
||||
export class TsTest {
|
||||
public testDir: TestDirectory;
|
||||
@@ -37,6 +49,9 @@ export class TsTest {
|
||||
public tsbundleInstance = new plugins.tsbundle.TsBundle();
|
||||
|
||||
public runtimeRegistry = new RuntimeAdapterRegistry();
|
||||
public dockerAdapter: DockerRuntimeAdapter | null = null;
|
||||
|
||||
private beforeScripts: IBeforeScripts | 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;
|
||||
@@ -60,6 +75,14 @@ export class TsTest {
|
||||
this.runtimeRegistry.register(
|
||||
new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
|
||||
);
|
||||
|
||||
// Initialize Docker adapter
|
||||
this.dockerAdapter = new DockerRuntimeAdapter(
|
||||
this.logger,
|
||||
this.smartshellInstance,
|
||||
this.timeoutSeconds,
|
||||
cwdArg
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +102,22 @@ export class TsTest {
|
||||
if (this.logger.options.logFile) {
|
||||
await this.movePreviousLogFiles();
|
||||
}
|
||||
|
||||
|
||||
// Load and execute test:before script (runs once per test run)
|
||||
this.beforeScripts = loadBeforeScripts(this.testDir.cwd);
|
||||
if (this.beforeScripts.beforeOnce) {
|
||||
const success = await runBeforeScript(
|
||||
this.smartshellInstance,
|
||||
this.beforeScripts.beforeOnce,
|
||||
'test:before',
|
||||
this.logger,
|
||||
);
|
||||
if (!success) {
|
||||
this.logger.error('test:before script failed. Aborting test run.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const testGroups = await this.testDir.getTestFileGroups();
|
||||
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
||||
|
||||
@@ -122,7 +160,7 @@ export class TsTest {
|
||||
}
|
||||
|
||||
public async runWatch(ignorePatterns: string[] = []) {
|
||||
const smartchokInstance = new plugins.smartchok.Smartchok([this.testDir.cwd]);
|
||||
const smartwatchInstance = new plugins.smartwatch.Smartwatch([this.testDir.cwd]);
|
||||
|
||||
console.clear();
|
||||
this.logger.watchModeStart();
|
||||
@@ -145,12 +183,12 @@ export class TsTest {
|
||||
};
|
||||
|
||||
// Start watching before subscribing to events
|
||||
await smartchokInstance.start();
|
||||
|
||||
await smartwatchInstance.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 changeObservable = await smartwatchInstance.getObservableFor('change');
|
||||
const addObservable = await smartwatchInstance.getObservableFor('add');
|
||||
const unlinkObservable = await smartwatchInstance.getObservableFor('unlink');
|
||||
|
||||
const handleFileChange = (changedPath: string) => {
|
||||
// Skip if path matches ignore patterns
|
||||
@@ -184,7 +222,7 @@ export class TsTest {
|
||||
// Handle Ctrl+C to exit gracefully
|
||||
process.on('SIGINT', async () => {
|
||||
this.logger.watchModeStop();
|
||||
await smartchokInstance.stop();
|
||||
await smartwatchInstance.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -211,8 +249,14 @@ export class TsTest {
|
||||
}
|
||||
|
||||
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
||||
// Parse the filename to determine runtimes and modifiers
|
||||
const fileName = plugins.path.basename(fileNameArg);
|
||||
|
||||
// Check if this is a Docker test file
|
||||
if (isDockerTestFile(fileName)) {
|
||||
return await this.runDockerTest(fileNameArg, fileIndex, totalFiles, tapCombinator);
|
||||
}
|
||||
|
||||
// Parse the filename to determine runtimes and modifiers (for TypeScript tests)
|
||||
const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
|
||||
|
||||
// Check for nonci modifier in CI environment
|
||||
@@ -240,24 +284,108 @@ export class TsTest {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse directives from the test file (e.g., // tstest:deno:allowAll)
|
||||
let directives = await parseDirectivesFromFile(fileNameArg);
|
||||
|
||||
// Also check for directives in 00init.ts
|
||||
const testDir = plugins.path.dirname(fileNameArg);
|
||||
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
||||
if (initFileExists) {
|
||||
const initDirectives = await parseDirectivesFromFile(initFile);
|
||||
directives = mergeDirectives(initDirectives, directives);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Run test:before:testfile if defined
|
||||
if (this.beforeScripts?.beforeTestfile) {
|
||||
const success = await runBeforeScript(
|
||||
this.smartshellInstance,
|
||||
this.beforeScripts.beforeTestfile,
|
||||
`test:before:testfile (${fileName})`,
|
||||
this.logger,
|
||||
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id },
|
||||
);
|
||||
if (!success) {
|
||||
this.logger.error(`test:before:testfile failed for ${fileName}. Skipping test file.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
|
||||
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
|
||||
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);
|
||||
|
||||
// Run test:before:testfile if defined (runs before each runtime)
|
||||
if (this.beforeScripts?.beforeTestfile) {
|
||||
const success = await runBeforeScript(
|
||||
this.smartshellInstance,
|
||||
this.beforeScripts.beforeTestfile,
|
||||
`test:before:testfile (${fileName} on ${adapter.displayName})`,
|
||||
this.logger,
|
||||
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id },
|
||||
);
|
||||
if (!success) {
|
||||
this.logger.error(`test:before:testfile failed for ${fileName} on ${adapter.displayName}. Skipping.`);
|
||||
this.logger.sectionEnd();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
|
||||
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
|
||||
tapCombinator.addTapParser(tapParser);
|
||||
this.logger.sectionEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Docker test file
|
||||
*/
|
||||
private async runDockerTest(
|
||||
fileNameArg: string,
|
||||
fileIndex: number,
|
||||
totalFiles: number,
|
||||
tapCombinator: TapCombinator
|
||||
): Promise<void> {
|
||||
if (!this.dockerAdapter) {
|
||||
this.logger.tapOutput(cs('❌ Docker adapter not initialized', 'red'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Run test:before:testfile if defined
|
||||
if (this.beforeScripts?.beforeTestfile) {
|
||||
const success = await runBeforeScript(
|
||||
this.smartshellInstance,
|
||||
this.beforeScripts.beforeTestfile,
|
||||
`test:before:testfile (${fileNameArg} on Docker)`,
|
||||
this.logger,
|
||||
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: 'docker' },
|
||||
);
|
||||
if (!success) {
|
||||
this.logger.error(`test:before:testfile failed for ${fileNameArg}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tapParser = await this.dockerAdapter.run(fileNameArg, fileIndex, totalFiles);
|
||||
tapCombinator.addTapParser(tapParser);
|
||||
} catch (error) {
|
||||
this.logger.tapOutput(cs(`❌ Docker test failed: ${error.message}`, 'red'));
|
||||
}
|
||||
}
|
||||
|
||||
public async runInNode(fileNameArg: string, index: number, total: number): Promise<TapParser> {
|
||||
this.logger.testFileStart(fileNameArg, 'node.js', index, total);
|
||||
const tapParser = new TapParser(fileNameArg + ':node', this.logger);
|
||||
@@ -278,8 +406,8 @@ export class TsTest {
|
||||
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||
let runCommand = `tsrun ${fileNameArg}${tsrunOptions}`;
|
||||
|
||||
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
|
||||
|
||||
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
||||
|
||||
// If 00init.ts exists, run it first
|
||||
if (initFileExists) {
|
||||
// Create a temporary loader file that imports both 00init.ts and the test file
|
||||
@@ -290,7 +418,7 @@ import '${absoluteInitFile.replace(/\\/g, '/')}';
|
||||
import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
`;
|
||||
const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`);
|
||||
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
|
||||
await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
|
||||
runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
|
||||
}
|
||||
|
||||
@@ -301,14 +429,14 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`);
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||
plugins.smartfile.fs.removeSync(loaderPath);
|
||||
if (plugins.fs.existsSync(loaderPath)) {
|
||||
plugins.fs.rmSync(loaderPath, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
execResultStreaming.childProcess.on('exit', cleanup);
|
||||
execResultStreaming.childProcess.on('error', cleanup);
|
||||
}
|
||||
@@ -407,7 +535,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
|
||||
|
||||
// lets bundle the test
|
||||
await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
|
||||
try { await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().delete(); } catch (e) { /* may not exist */ }
|
||||
await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().create();
|
||||
await this.tsbundleInstance.build(process.cwd(), fileNameArg, bundleFilePath, {
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
@@ -415,30 +544,28 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
// 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,
|
||||
// Use SmartServe with setHandler() to bypass global ControllerRegistry
|
||||
const fileServer = new plugins.smartserve.FileServer({ root: tsbundleCacheDirPath });
|
||||
const server = new plugins.smartserve.SmartServe({ port: httpPort });
|
||||
server.setHandler(async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/test') {
|
||||
return new Response(`
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
globalThis.testdom = true;
|
||||
globalThis.wsPort = ${wsPort};
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
const staticResponse = await fileServer.serve(request);
|
||||
if (staticResponse) return staticResponse;
|
||||
return new Response('Not Found', { status: 404 });
|
||||
});
|
||||
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
|
||||
@@ -602,34 +729,33 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||
|
||||
try {
|
||||
// Delete 00err and 00diff directories if they exist
|
||||
if (plugins.smartfile.fs.isDirectorySync(errDir)) {
|
||||
plugins.smartfile.fs.removeSync(errDir);
|
||||
if (plugins.fs.existsSync(errDir) && plugins.fs.statSync(errDir).isDirectory()) {
|
||||
plugins.fs.rmSync(errDir, { recursive: true, force: true });
|
||||
}
|
||||
if (plugins.smartfile.fs.isDirectorySync(diffDir)) {
|
||||
plugins.smartfile.fs.removeSync(diffDir);
|
||||
if (plugins.fs.existsSync(diffDir) && plugins.fs.statSync(diffDir).isDirectory()) {
|
||||
plugins.fs.rmSync(diffDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
// Get all .log files in log directory (not in subdirectories)
|
||||
const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log');
|
||||
const logFiles = files.filter((file: string) => !file.includes('/'));
|
||||
|
||||
const entries = await plugins.smartfsInstance.directory(logDir).filter('*.log').list();
|
||||
const logFiles = entries.filter((entry) => entry.isFile).map((entry) => entry.name);
|
||||
|
||||
if (logFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure previous directory exists
|
||||
await plugins.smartfile.fs.ensureDir(previousDir);
|
||||
|
||||
await plugins.smartfsInstance.directory(previousDir).recursive().create();
|
||||
|
||||
// Move each log file to previous directory
|
||||
for (const file of logFiles) {
|
||||
const filename = plugins.path.basename(file);
|
||||
for (const filename of logFiles) {
|
||||
const sourcePath = plugins.path.join(logDir, filename);
|
||||
const destPath = plugins.path.join(previousDir, filename);
|
||||
|
||||
|
||||
try {
|
||||
// Copy file to new location and remove original
|
||||
await plugins.smartfile.fs.copy(sourcePath, destPath);
|
||||
await plugins.smartfile.fs.remove(sourcePath);
|
||||
await plugins.smartfsInstance.file(sourcePath).copy(destPath);
|
||||
await plugins.smartfsInstance.file(sourcePath).delete();
|
||||
} catch (error) {
|
||||
// Silently continue if a file can't be moved
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export class TsTestLogger {
|
||||
private currentTestLogFile: string | null = null;
|
||||
private currentTestLogs: string[] = []; // Buffer for current test logs
|
||||
private currentTestFailed: boolean = false;
|
||||
private isOutputMidLine: boolean = false; // Track whether we're mid-line for streaming output
|
||||
|
||||
constructor(options: LogOptions = {}) {
|
||||
this.options = options;
|
||||
@@ -121,6 +122,40 @@ export class TsTestLogger {
|
||||
this.log(this.format(`[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${message}`, 'dim'));
|
||||
}
|
||||
|
||||
// Before-script lifecycle hooks
|
||||
beforeScriptStart(label: string, command: string) {
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'beforeScript', label, command });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.quiet) {
|
||||
this.log(`Running ${label}...`);
|
||||
} else {
|
||||
this.log(this.format(`\n🔧 Running ${label}...`, 'cyan'));
|
||||
this.log(this.format(` Command: ${command}`, 'dim'));
|
||||
}
|
||||
}
|
||||
|
||||
beforeScriptEnd(label: string, success: boolean, durationMs: number) {
|
||||
const durationStr = durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`;
|
||||
|
||||
if (this.options.json) {
|
||||
this.logJson({ event: 'beforeScriptEnd', label, success, durationMs });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.quiet) {
|
||||
this.log(success ? `${label} done (${durationStr})` : `${label} FAILED`);
|
||||
} else {
|
||||
if (success) {
|
||||
this.log(this.format(` ✓ ${label} completed (${durationStr})`, 'green'));
|
||||
} else {
|
||||
this.log(this.format(` ✗ ${label} failed (${durationStr})`, 'red'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test discovery
|
||||
testDiscovery(count: number, pattern: string, executionMode: string) {
|
||||
if (this.options.json) {
|
||||
@@ -189,6 +224,7 @@ export class TsTestLogger {
|
||||
// Reset test-specific state
|
||||
this.currentTestLogs = [];
|
||||
this.currentTestFailed = false;
|
||||
this.isOutputMidLine = false;
|
||||
|
||||
// Only set up test log file if --logfile option is specified
|
||||
if (this.options.logFile) {
|
||||
@@ -200,15 +236,18 @@ export class TsTestLogger {
|
||||
.replace(/\//g, '__') // Replace path separators with double underscores
|
||||
.replace(/\.ts$/, '') // Remove .ts extension
|
||||
.replace(/^\.\.__|^\.__|^__/, ''); // Clean up leading separators from relative paths
|
||||
|
||||
this.currentTestLogFile = path.join('.nogit', 'testlogs', `${safeFilename}.log`);
|
||||
|
||||
|
||||
// Sanitize runtime name for use in filename (lowercase, no spaces/dots/special chars)
|
||||
const safeRuntime = runtime.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
this.currentTestLogFile = path.join('.nogit', 'testlogs', `${safeFilename}__${safeRuntime}.log`);
|
||||
|
||||
// Ensure the directory exists
|
||||
const logDir = path.dirname(this.currentTestLogFile);
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
// Clear the log file for this test
|
||||
fs.writeFileSync(this.currentTestLogFile, '');
|
||||
}
|
||||
@@ -345,21 +384,73 @@ export class TsTestLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// Console output from test files (non-TAP output)
|
||||
// Console output from test files (non-TAP output) - complete lines
|
||||
testConsoleOutput(message: string) {
|
||||
if (this.options.json) return;
|
||||
|
||||
|
||||
const prefix = ' ';
|
||||
// In verbose mode, show console output immediately
|
||||
if (this.options.verbose) {
|
||||
this.log(this.format(` ${message}`, 'dim'));
|
||||
// Only add prefix if we're starting a new line
|
||||
const output = this.isOutputMidLine ? message : prefix + message;
|
||||
this.log(this.format(output, 'dim'));
|
||||
} else {
|
||||
// In non-verbose mode, buffer the logs
|
||||
this.currentTestLogs.push(message);
|
||||
if (this.isOutputMidLine && this.currentTestLogs.length > 0) {
|
||||
// Append to the last buffered entry since we're mid-line
|
||||
this.currentTestLogs[this.currentTestLogs.length - 1] += message;
|
||||
} else {
|
||||
this.currentTestLogs.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Reset mid-line state since we just output a complete line
|
||||
this.isOutputMidLine = false;
|
||||
|
||||
// Always log to test file if --logfile is specified
|
||||
if (this.currentTestLogFile) {
|
||||
this.logToTestFile(` ${message}`);
|
||||
this.logToTestFile(`${prefix}${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming console output (preserves original formatting, no newline added)
|
||||
testConsoleOutputStreaming(message: string) {
|
||||
if (this.options.json) return;
|
||||
|
||||
const prefix = ' ';
|
||||
// Only add prefix if we're starting a new line (not mid-line)
|
||||
const output = this.isOutputMidLine ? message : prefix + message;
|
||||
|
||||
if (this.options.verbose) {
|
||||
// Use process.stdout.write to preserve streaming without adding newlines
|
||||
process.stdout.write(this.format(output, 'dim'));
|
||||
} else {
|
||||
// Buffer mode: append to last entry if mid-line
|
||||
if (this.isOutputMidLine && this.currentTestLogs.length > 0) {
|
||||
this.currentTestLogs[this.currentTestLogs.length - 1] += message;
|
||||
} else {
|
||||
this.currentTestLogs.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Log to test file without adding newline
|
||||
if (this.currentTestLogFile) {
|
||||
this.logToTestFileRaw(output);
|
||||
}
|
||||
|
||||
// We're now mid-line (no newline was written)
|
||||
this.isOutputMidLine = true;
|
||||
}
|
||||
|
||||
private logToTestFileRaw(message: string) {
|
||||
try {
|
||||
// Remove ANSI color codes for file logging
|
||||
const cleanMessage = message.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
|
||||
// Append to test log file without adding newline
|
||||
fs.appendFileSync(this.currentTestLogFile, cleanMessage);
|
||||
} catch (error) {
|
||||
// Silently fail to avoid disrupting the test run
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
// node native
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedserver from '@api.global/typedserver';
|
||||
|
||||
export {
|
||||
typedserver
|
||||
}
|
||||
export { fs, path };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as consolecolor from '@push.rocks/consolecolor';
|
||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||
import * as smartchok from '@push.rocks/smartchok';
|
||||
import * as smartserve from '@push.rocks/smartserve';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
const smartfsInstance = new smartfs.SmartFs(new smartfs.SmartFsProviderNode());
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
import * as smartwatch from '@push.rocks/smartwatch';
|
||||
import * as tapbundle from '../dist_ts_tapbundle/index.js';
|
||||
|
||||
export {
|
||||
consolecolor,
|
||||
smartbrowser,
|
||||
smartchok,
|
||||
smartserve,
|
||||
smartdelay,
|
||||
smartfile,
|
||||
smartfs,
|
||||
smartfsInstance,
|
||||
smartlog,
|
||||
smartnetwork,
|
||||
smartpromise,
|
||||
smartshell,
|
||||
smartwatch,
|
||||
tapbundle,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
pnpm install --save-dev @git.zone/tstest
|
||||
```
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 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.
|
||||
@@ -91,6 +95,24 @@ tap.testParallel('should fetch user data', async () => {
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** The `tap.parallel().test()` fluent API is now the recommended way to define parallel tests (see Fluent API section below).
|
||||
|
||||
#### `tap.parallel()`
|
||||
|
||||
Returns a fluent test builder configured for parallel execution.
|
||||
|
||||
```typescript
|
||||
tap.parallel().test('should fetch data', async () => {
|
||||
// Parallel test
|
||||
});
|
||||
|
||||
// With full configuration
|
||||
tap.parallel()
|
||||
.tags('api')
|
||||
.retry(2)
|
||||
.test('configured parallel test', async () => {});
|
||||
```
|
||||
|
||||
#### `tap.describe(description, suiteFunction)`
|
||||
|
||||
Create a test suite to group related tests.
|
||||
@@ -141,22 +163,56 @@ tap
|
||||
});
|
||||
```
|
||||
|
||||
#### Parallel Tests with Fluent API
|
||||
|
||||
Use `tap.parallel()` to create parallel tests with fluent configuration:
|
||||
|
||||
```typescript
|
||||
// Simple parallel test
|
||||
tap.parallel().test('fetches user data', async () => {
|
||||
// Runs in parallel with other parallel tests
|
||||
});
|
||||
|
||||
// Parallel test with full configuration
|
||||
tap
|
||||
.parallel()
|
||||
.tags('api', 'integration')
|
||||
.retry(2)
|
||||
.timeout(5000)
|
||||
.test('should fetch data concurrently', async () => {
|
||||
// Configured parallel test
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** `tap.parallel().test()` is the recommended way to define parallel tests. The older `tap.testParallel()` method is still supported for backward compatibility.
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
#### Suite-Level Hooks
|
||||
|
||||
```typescript
|
||||
tap.describe('Database Tests', () => {
|
||||
tap.beforeAll(async (tapTools) => {
|
||||
// Runs once before all tests in this suite
|
||||
await initializeDatabaseConnection();
|
||||
});
|
||||
|
||||
tap.beforeEach(async (tapTools) => {
|
||||
// Runs before each test in this suite
|
||||
await clearTestData();
|
||||
});
|
||||
|
||||
tap.test('test 1', async () => { });
|
||||
tap.test('test 2', async () => { });
|
||||
|
||||
tap.afterEach(async (tapTools) => {
|
||||
// Runs after each test in this suite
|
||||
});
|
||||
|
||||
tap.test('test 1', async () => { });
|
||||
tap.test('test 2', async () => { });
|
||||
tap.afterAll(async (tapTools) => {
|
||||
// Runs once after all tests in this suite
|
||||
await closeDatabaseConnection();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
@@ -267,38 +323,169 @@ TSTEST_FILTER_TAGS=unit tstest test/mytest.node.ts
|
||||
|
||||
Each test receives a `tapTools` instance with utilities:
|
||||
|
||||
#### Test Control Methods
|
||||
|
||||
```typescript
|
||||
tap.test('should have utilities', async (tapTools) => {
|
||||
// Mark test as skipped
|
||||
tap.test('test control examples', async (tapTools) => {
|
||||
// Skip this test
|
||||
tapTools.skip('reason');
|
||||
|
||||
// Conditionally skip
|
||||
tapTools.skipIf(condition, 'reason');
|
||||
|
||||
// Mark test as skipped before execution
|
||||
tapTools.markAsSkipped('reason');
|
||||
|
||||
// Mark as todo
|
||||
tapTools.todo('not implemented');
|
||||
|
||||
// Allow test to fail without marking suite as failed
|
||||
tapTools.allowFailure();
|
||||
|
||||
// Configure retries
|
||||
tapTools.retry(3);
|
||||
|
||||
// Log test output
|
||||
tapTools.log('debug message');
|
||||
// Set timeout
|
||||
tapTools.timeout(5000);
|
||||
});
|
||||
```
|
||||
|
||||
#### Utility Methods
|
||||
|
||||
```typescript
|
||||
tap.test('utility examples', async (tapTools) => {
|
||||
// Delay execution
|
||||
await tapTools.delayFor(1000); // Wait 1 second
|
||||
await tapTools.delayForRandom(500, 1500); // Random delay
|
||||
|
||||
// Colored console output
|
||||
tapTools.coloredString('✓ Success', 'green');
|
||||
tapTools.coloredString('✗ Error', 'red');
|
||||
});
|
||||
```
|
||||
|
||||
#### Context and Data Sharing
|
||||
|
||||
```typescript
|
||||
tap.test('first test', async (tapTools) => {
|
||||
// Store data in context
|
||||
tapTools.context.set('userId', '12345');
|
||||
|
||||
// Store in testData property
|
||||
tapTools.testData = { username: 'alice' };
|
||||
});
|
||||
|
||||
tap.test('second test', async (tapTools) => {
|
||||
// Retrieve from context
|
||||
const userId = tapTools.context.get('userId');
|
||||
|
||||
// Check existence
|
||||
if (tapTools.context.has('userId')) {
|
||||
// Use data
|
||||
}
|
||||
|
||||
// Clear context
|
||||
tapTools.context.clear();
|
||||
});
|
||||
```
|
||||
|
||||
#### Fixtures
|
||||
|
||||
```typescript
|
||||
// Define a fixture globally (outside tests)
|
||||
import { TapTools } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
TapTools.defineFixture('database', async () => {
|
||||
const db = await createTestDatabase();
|
||||
return {
|
||||
value: db,
|
||||
cleanup: async () => await db.close()
|
||||
};
|
||||
});
|
||||
|
||||
// Use fixtures in tests
|
||||
tap.test('database test', async (tapTools) => {
|
||||
const db = await tapTools.fixture('database');
|
||||
// Use db...
|
||||
// Cleanup happens automatically
|
||||
});
|
||||
```
|
||||
|
||||
#### Factory Pattern
|
||||
|
||||
```typescript
|
||||
// Define a factory
|
||||
TapTools.defineFixture('user', async () => {
|
||||
return {
|
||||
value: null, // Not used for factories
|
||||
factory: async (data) => {
|
||||
return await createUser(data);
|
||||
},
|
||||
cleanup: async (user) => await user.delete()
|
||||
};
|
||||
});
|
||||
|
||||
// Use factory in tests
|
||||
tap.test('user test', async (tapTools) => {
|
||||
const user = await tapTools.factory('user').create({ name: 'Alice' });
|
||||
|
||||
// Create multiple
|
||||
const users = await tapTools.factory('user').createMany([
|
||||
{ name: 'Alice' },
|
||||
{ name: 'Bob' }
|
||||
]);
|
||||
|
||||
// Cleanup happens automatically
|
||||
});
|
||||
```
|
||||
|
||||
#### Snapshot Testing
|
||||
|
||||
```typescript
|
||||
tap.test('snapshot test', async (tapTools) => {
|
||||
const result = { name: 'Alice', age: 30 };
|
||||
|
||||
// Compare with stored snapshot
|
||||
await tapTools.matchSnapshot(result);
|
||||
|
||||
// Named snapshots
|
||||
await tapTools.matchSnapshot(result, 'user-data');
|
||||
});
|
||||
```
|
||||
|
||||
To update snapshots, run with:
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true tstest test/mytest.ts
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Pre-Tasks
|
||||
### Pre-Tasks and Post-Tasks
|
||||
|
||||
Run setup tasks before any tests execute:
|
||||
Run setup and teardown tasks before/after all tests:
|
||||
|
||||
```typescript
|
||||
tap.preTask('setup database', async () => {
|
||||
// Runs before any tests
|
||||
await initializeDatabase();
|
||||
});
|
||||
|
||||
tap.test('first test', async () => {
|
||||
// Database is ready
|
||||
});
|
||||
|
||||
tap.test('second test', async () => {
|
||||
// Tests run...
|
||||
});
|
||||
|
||||
tap.postTask('cleanup database', async () => {
|
||||
// Runs after all tests complete
|
||||
await cleanupDatabase();
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** Post tasks run after all tests but before the global `afterAll` hook.
|
||||
|
||||
### Test Priority
|
||||
|
||||
Organize tests by priority level:
|
||||
@@ -334,6 +521,50 @@ import { setProtocolEmitter } from '@git.zone/tstest/tapbundle';
|
||||
// Events: test:started, test:completed, assertion:failed, suite:started, suite:completed
|
||||
```
|
||||
|
||||
### Additional Tap Methods
|
||||
|
||||
#### Configuration and Inspection
|
||||
|
||||
```typescript
|
||||
// Get current test settings
|
||||
const settings = tap.getSettings();
|
||||
console.log(settings.timeout, settings.retries);
|
||||
|
||||
// Explicitly fail a test
|
||||
tap.test('validation test', async () => {
|
||||
if (invalidCondition) {
|
||||
tap.fail('Custom failure message');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Advanced Control
|
||||
|
||||
```typescript
|
||||
// Force stop test execution
|
||||
tap.stopForcefully(exitCode, immediate);
|
||||
|
||||
// Handle thrown errors (internal use)
|
||||
tap.threw(error);
|
||||
```
|
||||
|
||||
#### Parallel Test Variants
|
||||
|
||||
In addition to `tap.parallel().test()`, skip/only/todo modes also support parallel execution:
|
||||
|
||||
```typescript
|
||||
// Skip parallel test
|
||||
tap.skip.testParallel('not ready', async () => {});
|
||||
|
||||
// Only run this parallel test
|
||||
tap.only.testParallel('focus here', async () => {});
|
||||
|
||||
// Todo parallel test
|
||||
tap.todo.testParallel('implement later');
|
||||
```
|
||||
|
||||
**Note:** Using `tap.parallel()` fluent API is recommended over these direct methods.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always export `tap.start()`** at the end of test files:
|
||||
@@ -382,8 +613,23 @@ tap.test('should use context', async (tapTools) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Legal
|
||||
## License and Legal Information
|
||||
|
||||
This project is licensed under MIT.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
|
||||
© 2025 Task Venture Capital GmbH. All rights reserved.
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
21
ts_tapbundle/tapbundle.classes.posttask.ts
Normal file
21
ts_tapbundle/tapbundle.classes.posttask.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as plugins from './tapbundle.plugins.js';
|
||||
import { TapTools } from './tapbundle.classes.taptools.js';
|
||||
|
||||
export interface IPostTaskFunction {
|
||||
(tapTools?: TapTools): Promise<any>;
|
||||
}
|
||||
|
||||
export class PostTask {
|
||||
public description: string;
|
||||
public postTaskFunction: IPostTaskFunction;
|
||||
|
||||
constructor(descriptionArg: string, postTaskFunctionArg: IPostTaskFunction) {
|
||||
this.description = descriptionArg;
|
||||
this.postTaskFunction = postTaskFunctionArg;
|
||||
}
|
||||
|
||||
public async run() {
|
||||
console.log(`::__POSTTASK: ${this.description}`);
|
||||
await this.postTaskFunction(new TapTools(null));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as plugins from './tapbundle.plugins.js';
|
||||
|
||||
import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js';
|
||||
import { type IPostTaskFunction, PostTask } from './tapbundle.classes.posttask.js';
|
||||
import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js';
|
||||
import { TapTools } from './tapbundle.classes.taptools.js';
|
||||
import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js';
|
||||
import type { ITapSettings } from './tapbundle.interfaces.js';
|
||||
import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
|
||||
@@ -9,6 +11,8 @@ import { SettingsManager } from './tapbundle.classes.settingsmanager.js';
|
||||
export interface ITestSuite {
|
||||
description: string;
|
||||
tests: TapTest<any>[];
|
||||
beforeAll?: ITestFunction<any>;
|
||||
afterAll?: ITestFunction<any>;
|
||||
beforeEach?: ITestFunction<any>;
|
||||
afterEach?: ITestFunction<any>;
|
||||
parent?: ITestSuite;
|
||||
@@ -21,85 +25,89 @@ class TestBuilder<T> {
|
||||
private _priority: 'high' | 'medium' | 'low' = 'medium';
|
||||
private _retryCount?: number;
|
||||
private _timeoutMs?: number;
|
||||
|
||||
constructor(tap: Tap<T>) {
|
||||
private _parallel: boolean = false;
|
||||
|
||||
constructor(tap: Tap<T>, parallel: boolean = false) {
|
||||
this._tap = tap;
|
||||
this._parallel = parallel;
|
||||
}
|
||||
|
||||
|
||||
tags(...tags: string[]) {
|
||||
this._tags = tags;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
priority(level: 'high' | 'medium' | 'low') {
|
||||
this._priority = level;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
retry(count: number) {
|
||||
this._retryCount = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
timeout(ms: number) {
|
||||
this._timeoutMs = ms;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
test(description: string, testFunction: ITestFunction<T>) {
|
||||
const test = this._tap.test(description, testFunction, 'normal');
|
||||
|
||||
const test = this._parallel
|
||||
? this._tap.testParallel(description, testFunction)
|
||||
: this._tap.test(description, testFunction, 'normal');
|
||||
|
||||
// Apply settings to the test
|
||||
if (this._tags.length > 0) {
|
||||
test.tags = this._tags;
|
||||
}
|
||||
test.priority = this._priority;
|
||||
|
||||
|
||||
if (this._retryCount !== undefined) {
|
||||
test.tapTools.retry(this._retryCount);
|
||||
}
|
||||
if (this._timeoutMs !== undefined) {
|
||||
test.timeoutMs = this._timeoutMs;
|
||||
}
|
||||
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
|
||||
testOnly(description: string, testFunction: ITestFunction<T>) {
|
||||
const test = this._tap.test(description, testFunction, 'only');
|
||||
|
||||
|
||||
// Apply settings to the test
|
||||
if (this._tags.length > 0) {
|
||||
test.tags = this._tags;
|
||||
}
|
||||
test.priority = this._priority;
|
||||
|
||||
|
||||
if (this._retryCount !== undefined) {
|
||||
test.tapTools.retry(this._retryCount);
|
||||
}
|
||||
if (this._timeoutMs !== undefined) {
|
||||
test.timeoutMs = this._timeoutMs;
|
||||
}
|
||||
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
|
||||
testSkip(description: string, testFunction: ITestFunction<T>) {
|
||||
const test = this._tap.test(description, testFunction, 'skip');
|
||||
|
||||
|
||||
// Apply settings to the test
|
||||
if (this._tags.length > 0) {
|
||||
test.tags = this._tags;
|
||||
}
|
||||
test.priority = this._priority;
|
||||
|
||||
|
||||
if (this._retryCount !== undefined) {
|
||||
test.tapTools.retry(this._retryCount);
|
||||
}
|
||||
if (this._timeoutMs !== undefined) {
|
||||
test.timeoutMs = this._timeoutMs;
|
||||
}
|
||||
|
||||
|
||||
return test;
|
||||
}
|
||||
}
|
||||
@@ -122,21 +130,25 @@ export class Tap<T> {
|
||||
const builder = new TestBuilder<T>(this);
|
||||
return builder.tags(...tags);
|
||||
}
|
||||
|
||||
|
||||
public priority(level: 'high' | 'medium' | 'low') {
|
||||
const builder = new TestBuilder<T>(this);
|
||||
return builder.priority(level);
|
||||
}
|
||||
|
||||
|
||||
public retry(count: number) {
|
||||
const builder = new TestBuilder<T>(this);
|
||||
return builder.retry(count);
|
||||
}
|
||||
|
||||
|
||||
public timeout(ms: number) {
|
||||
const builder = new TestBuilder<T>(this);
|
||||
return builder.timeout(ms);
|
||||
}
|
||||
|
||||
public parallel() {
|
||||
return new TestBuilder<T>(this, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* skips a test
|
||||
@@ -236,6 +248,7 @@ export class Tap<T> {
|
||||
};
|
||||
|
||||
private _tapPreTasks: PreTask[] = [];
|
||||
private _tapPostTasks: PostTask[] = [];
|
||||
private _tapTests: TapTest<any>[] = [];
|
||||
private _tapTestsOnly: TapTest<any>[] = [];
|
||||
private _currentSuite: ITestSuite | null = null;
|
||||
@@ -304,18 +317,22 @@ export class Tap<T> {
|
||||
this._tapPreTasks.push(new PreTask(descriptionArg, functionArg));
|
||||
}
|
||||
|
||||
public postTask(descriptionArg: string, functionArg: IPostTaskFunction) {
|
||||
this._tapPostTasks.push(new PostTask(descriptionArg, functionArg));
|
||||
}
|
||||
|
||||
/**
|
||||
* A parallel test that will not be waited for before the next starts.
|
||||
* @param testDescription - A description of what the test does
|
||||
* @param testFunction - A Function that returns a Promise and resolves or rejects
|
||||
*/
|
||||
public testParallel(testDescription: string, testFunction: ITestFunction<T>) {
|
||||
public testParallel(testDescription: string, testFunction: ITestFunction<T>): TapTest<T> {
|
||||
const localTest = new TapTest({
|
||||
description: testDescription,
|
||||
testFunction,
|
||||
parallel: true,
|
||||
});
|
||||
|
||||
|
||||
// Apply default settings from settings manager
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.timeout !== undefined) {
|
||||
@@ -324,12 +341,14 @@ export class Tap<T> {
|
||||
if (settings.retries !== undefined) {
|
||||
localTest.tapTools.retry(settings.retries);
|
||||
}
|
||||
|
||||
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.tests.push(localTest);
|
||||
} else {
|
||||
this._tapTests.push(localTest);
|
||||
}
|
||||
|
||||
return localTest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,6 +379,28 @@ export class Tap<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a function to run once before all tests in the current suite
|
||||
*/
|
||||
public beforeAll(setupFunction: ITestFunction<any>) {
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.beforeAll = setupFunction;
|
||||
} else {
|
||||
throw new Error('beforeAll can only be used inside a describe block');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a function to run once after all tests in the current suite
|
||||
*/
|
||||
public afterAll(teardownFunction: ITestFunction<any>) {
|
||||
if (this._currentSuite) {
|
||||
this._currentSuite.afterAll = teardownFunction;
|
||||
} else {
|
||||
throw new Error('afterAll can only be used inside a describe block');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a function to run before each test in the current suite
|
||||
*/
|
||||
@@ -370,7 +411,7 @@ export class Tap<T> {
|
||||
throw new Error('beforeEach can only be used inside a describe block');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set up a function to run after each test in the current suite
|
||||
*/
|
||||
@@ -554,6 +595,11 @@ export class Tap<T> {
|
||||
console.log(failReason);
|
||||
}
|
||||
|
||||
// Run post tasks
|
||||
for (const postTask of this._tapPostTasks) {
|
||||
await postTask.run();
|
||||
}
|
||||
|
||||
// Run global afterAll hook if configured
|
||||
if (settings.afterAll) {
|
||||
try {
|
||||
@@ -597,6 +643,12 @@ export class Tap<T> {
|
||||
suiteName: suite.description
|
||||
}
|
||||
});
|
||||
|
||||
// Run beforeAll hook for this suite
|
||||
if (suite.beforeAll) {
|
||||
await suite.beforeAll(new TapTools(null as any));
|
||||
}
|
||||
|
||||
// Run beforeEach from parent suites
|
||||
const beforeEachFunctions: ITestFunction<any>[] = [];
|
||||
let currentSuite: ITestSuite | null = suite;
|
||||
@@ -666,7 +718,12 @@ export class Tap<T> {
|
||||
|
||||
// Recursively run child suites
|
||||
await this._runSuite(suite, suite.children, promiseArray, context);
|
||||
|
||||
|
||||
// Run afterAll hook for this suite
|
||||
if (suite.afterAll) {
|
||||
await suite.afterAll(new TapTools(null as any));
|
||||
}
|
||||
|
||||
// Emit suite:completed event
|
||||
this.emitEvent({
|
||||
eventType: 'suite:completed',
|
||||
|
||||
@@ -261,7 +261,7 @@ export class TapTest<T = unknown> {
|
||||
|
||||
// Final failure
|
||||
const testResult = {
|
||||
ok: false,
|
||||
ok: this.failureAllowed, // Pass if failure is allowed
|
||||
testNumber,
|
||||
description: this.description,
|
||||
metadata: {
|
||||
|
||||
@@ -22,10 +22,24 @@ class WebHelpers {
|
||||
|
||||
// Initialize fixture function based on environment
|
||||
if (smartenv.isBrowser) {
|
||||
this.fixture = async (htmlString: string): Promise<HTMLElement> => {
|
||||
this.fixture = async <T extends HTMLElement>(htmlString: string): Promise<T> => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = htmlString.trim();
|
||||
const element = container.firstChild as HTMLElement;
|
||||
const element = container.firstElementChild as T;
|
||||
|
||||
// Append to document so custom elements upgrade and lifecycle hooks fire
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for custom element definition if it's a custom element
|
||||
if (element.localName.includes('-')) {
|
||||
await customElements.whenDefined(element.localName).catch(() => {});
|
||||
}
|
||||
|
||||
// Wait for Lit/async components to finish rendering
|
||||
if ((element as any).updateComplete) {
|
||||
await (element as any).updateComplete;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { TestFileProvider } from './classes.testfileprovider.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
class TapNodeTools {
|
||||
private smartshellInstance: plugins.smartshell.Smartshell;
|
||||
public testFileProvider = new TestFileProvider();
|
||||
|
||||
constructor() {}
|
||||
|
||||
private qenv: plugins.qenv.Qenv;
|
||||
public async getQenv(): Promise<plugins.qenv.Qenv> {
|
||||
this.qenv = this.qenv || new plugins.qenv.Qenv('./', '.nogit/');
|
||||
return this.qenv;
|
||||
}
|
||||
public async getEnvVarOnDemand(envVarNameArg: string): Promise<string> {
|
||||
const qenv = await this.getQenv();
|
||||
return qenv.getEnvVarOnDemand(envVarNameArg);
|
||||
}
|
||||
|
||||
public async runCommand(commandArg: string): Promise<any> {
|
||||
if (!this.smartshellInstance) {
|
||||
this.smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
});
|
||||
}
|
||||
const result = await this.smartshellInstance.exec(commandArg);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async createHttpsCert(
|
||||
commonName: string = 'localhost',
|
||||
allowSelfSigned: boolean = true
|
||||
): Promise<{ key: string; cert: string }> {
|
||||
if (allowSelfSigned) {
|
||||
// set node to allow self-signed certificates
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
// Generate a key pair
|
||||
const keys = plugins.smartcrypto.nodeForge.pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create a self-signed certificate
|
||||
const cert = plugins.smartcrypto.nodeForge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = '01';
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [
|
||||
{ name: 'commonName', value: commonName },
|
||||
{ name: 'countryName', value: 'US' },
|
||||
{ shortName: 'ST', value: 'California' },
|
||||
{ name: 'localityName', value: 'San Francisco' },
|
||||
{ name: 'organizationName', value: 'My Company' },
|
||||
{ shortName: 'OU', value: 'Dev' },
|
||||
];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// Sign the certificate with its own private key (self-signed)
|
||||
cert.sign(keys.privateKey, plugins.smartcrypto.nodeForge.md.sha256.create());
|
||||
|
||||
// PEM encode the private key and certificate
|
||||
const pemKey = plugins.smartcrypto.nodeForge.pki.privateKeyToPem(keys.privateKey);
|
||||
const pemCert = plugins.smartcrypto.nodeForge.pki.certificateToPem(cert);
|
||||
|
||||
return {
|
||||
key: pemKey,
|
||||
cert: pemCert,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* create and return a smartmongo instance
|
||||
*/
|
||||
public async createSmartmongo() {
|
||||
const smartmongoMod = await import('@push.rocks/smartmongo');
|
||||
const smartmongoInstance = new smartmongoMod.SmartMongo();
|
||||
await smartmongoInstance.start();
|
||||
return smartmongoInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* create and return a smarts3 instance
|
||||
*/
|
||||
public async createSmarts3() {
|
||||
const smarts3Mod = await import('@push.rocks/smarts3');
|
||||
const smarts3Instance = new smarts3Mod.Smarts3({
|
||||
port: 3003,
|
||||
cleanSlate: true,
|
||||
});
|
||||
await smarts3Instance.start();
|
||||
return smarts3Instance;
|
||||
}
|
||||
}
|
||||
|
||||
export const tapNodeTools = new TapNodeTools();
|
||||
@@ -1,367 +0,0 @@
|
||||
# @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.
|
||||
@@ -9,6 +9,10 @@
|
||||
pnpm install --save-dev @git.zone/tstest
|
||||
```
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 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.
|
||||
@@ -580,8 +584,23 @@ This module works in all JavaScript environments:
|
||||
|
||||
No runtime-specific APIs are used, making it truly portable.
|
||||
|
||||
## Legal
|
||||
## License and Legal Information
|
||||
|
||||
This project is licensed under MIT.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
|
||||
© 2025 Task Venture Capital GmbH. All rights reserved.
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
222
ts_tapbundle_serverside/classes.tapnodetools.ts
Normal file
222
ts_tapbundle_serverside/classes.tapnodetools.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { TestFileProvider } from './classes.testfileprovider.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
class TapNodeTools {
|
||||
private smartshellInstance: plugins.smartshell.Smartshell;
|
||||
private smartnetworkInstance: plugins.smartnetwork.SmartNetwork;
|
||||
public testFileProvider = new TestFileProvider();
|
||||
|
||||
constructor() {}
|
||||
|
||||
private qenv: plugins.qenv.Qenv;
|
||||
public async getQenv(): Promise<plugins.qenv.Qenv> {
|
||||
this.qenv = this.qenv || new plugins.qenv.Qenv('./', '.nogit/');
|
||||
return this.qenv;
|
||||
}
|
||||
public async getEnvVarOnDemand(envVarNameArg: string): Promise<string> {
|
||||
const qenv = await this.getQenv();
|
||||
return qenv.getEnvVarOnDemand(envVarNameArg);
|
||||
}
|
||||
|
||||
public async runCommand(commandArg: string): Promise<any> {
|
||||
if (!this.smartshellInstance) {
|
||||
this.smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
});
|
||||
}
|
||||
const result = await this.smartshellInstance.exec(commandArg);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async createHttpsCert(
|
||||
commonName: string = 'localhost',
|
||||
allowSelfSigned: boolean = true
|
||||
): Promise<{ key: string; cert: string }> {
|
||||
if (allowSelfSigned) {
|
||||
// set node to allow self-signed certificates
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
// Generate a key pair
|
||||
const keys = plugins.smartcrypto.nodeForge.pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create a self-signed certificate
|
||||
const cert = plugins.smartcrypto.nodeForge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = '01';
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [
|
||||
{ name: 'commonName', value: commonName },
|
||||
{ name: 'countryName', value: 'US' },
|
||||
{ shortName: 'ST', value: 'California' },
|
||||
{ name: 'localityName', value: 'San Francisco' },
|
||||
{ name: 'organizationName', value: 'My Company' },
|
||||
{ shortName: 'OU', value: 'Dev' },
|
||||
];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// Sign the certificate with its own private key (self-signed)
|
||||
cert.sign(keys.privateKey, plugins.smartcrypto.nodeForge.md.sha256.create());
|
||||
|
||||
// PEM encode the private key and certificate
|
||||
const pemKey = plugins.smartcrypto.nodeForge.pki.privateKeyToPem(keys.privateKey);
|
||||
const pemCert = plugins.smartcrypto.nodeForge.pki.certificateToPem(cert);
|
||||
|
||||
return {
|
||||
key: pemKey,
|
||||
cert: pemCert,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* create and return a smartmongo instance
|
||||
*/
|
||||
public async createSmartmongo() {
|
||||
const smartmongoMod = await import('@push.rocks/smartmongo');
|
||||
const smartmongoInstance = new smartmongoMod.SmartMongo();
|
||||
await smartmongoInstance.start();
|
||||
return smartmongoInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* create and return a smartstorage instance
|
||||
*/
|
||||
public async createSmartStorage() {
|
||||
const smartstorageMod = await import('@push.rocks/smartstorage');
|
||||
const smartstorageInstance = await smartstorageMod.SmartStorage.createAndStart({
|
||||
server: { port: 3003 },
|
||||
storage: { cleanSlate: true },
|
||||
});
|
||||
return smartstorageInstance;
|
||||
}
|
||||
|
||||
// ============
|
||||
// Network Tools
|
||||
// ============
|
||||
|
||||
private getSmartNetwork(): plugins.smartnetwork.SmartNetwork {
|
||||
if (!this.smartnetworkInstance) {
|
||||
this.smartnetworkInstance = new plugins.smartnetwork.SmartNetwork();
|
||||
}
|
||||
return this.smartnetworkInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single free port on the local machine.
|
||||
*/
|
||||
public async findFreePort(optionsArg?: {
|
||||
startPort?: number;
|
||||
endPort?: number;
|
||||
randomize?: boolean;
|
||||
exclude?: number[];
|
||||
}): Promise<number> {
|
||||
const options = {
|
||||
startPort: 3000,
|
||||
endPort: 60000,
|
||||
randomize: true,
|
||||
exclude: [] as number[],
|
||||
...optionsArg,
|
||||
};
|
||||
const smartnetwork = this.getSmartNetwork();
|
||||
const port = await smartnetwork.findFreePort(options.startPort, options.endPort, {
|
||||
randomize: options.randomize,
|
||||
exclude: options.exclude,
|
||||
});
|
||||
if (!port) {
|
||||
throw new Error(
|
||||
`Could not find a free port in range ${options.startPort}-${options.endPort}`
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find multiple distinct free ports on the local machine.
|
||||
* Each found port is automatically excluded from subsequent searches.
|
||||
*/
|
||||
public async findFreePorts(countArg: number, optionsArg?: {
|
||||
startPort?: number;
|
||||
endPort?: number;
|
||||
randomize?: boolean;
|
||||
exclude?: number[];
|
||||
}): Promise<number[]> {
|
||||
const options = {
|
||||
startPort: 3000,
|
||||
endPort: 60000,
|
||||
randomize: true,
|
||||
exclude: [] as number[],
|
||||
...optionsArg,
|
||||
};
|
||||
const smartnetwork = this.getSmartNetwork();
|
||||
const ports: number[] = [];
|
||||
const excluded = new Set(options.exclude);
|
||||
|
||||
for (let i = 0; i < countArg; i++) {
|
||||
const port = await smartnetwork.findFreePort(options.startPort, options.endPort, {
|
||||
randomize: options.randomize,
|
||||
exclude: [...excluded],
|
||||
});
|
||||
if (!port) {
|
||||
throw new Error(
|
||||
`Could only find ${ports.length} of ${countArg} free ports in range ${options.startPort}-${options.endPort}`
|
||||
);
|
||||
}
|
||||
ports.push(port);
|
||||
excluded.add(port);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a range of consecutive free ports on the local machine.
|
||||
* All returned ports are sequential (e.g., [4000, 4001, 4002]).
|
||||
*/
|
||||
public async findFreePortRange(countArg: number, optionsArg?: {
|
||||
startPort?: number;
|
||||
endPort?: number;
|
||||
exclude?: number[];
|
||||
}): Promise<number[]> {
|
||||
const options = {
|
||||
startPort: 3000,
|
||||
endPort: 60000,
|
||||
exclude: [] as number[],
|
||||
...optionsArg,
|
||||
};
|
||||
const smartnetwork = this.getSmartNetwork();
|
||||
const excludeSet = new Set(options.exclude);
|
||||
|
||||
for (let start = options.startPort; start <= options.endPort - countArg + 1; start++) {
|
||||
let allFree = true;
|
||||
for (let offset = 0; offset < countArg; offset++) {
|
||||
const port = start + offset;
|
||||
if (excludeSet.has(port)) {
|
||||
allFree = false;
|
||||
start = port; // skip ahead past excluded port
|
||||
break;
|
||||
}
|
||||
const isFree = await smartnetwork.isLocalPortUnused(port);
|
||||
if (!isFree) {
|
||||
allFree = false;
|
||||
start = port; // skip ahead past occupied port
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allFree) {
|
||||
const ports: number[] = [];
|
||||
for (let offset = 0; offset < countArg; offset++) {
|
||||
ports.push(start + offset);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find ${countArg} consecutive free ports in range ${options.startPort}-${options.endPort}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const tapNodeTools = new TapNodeTools();
|
||||
@@ -12,9 +12,9 @@ export class TestFileProvider {
|
||||
const response = await plugins.smartrequest.SmartRequest.create()
|
||||
.url(fileUrls.dockerAlpineImage)
|
||||
.get();
|
||||
await plugins.smartfile.fs.ensureDir(paths.testFilesDir);
|
||||
await plugins.smartfsInstance.directory(paths.testFilesDir).recursive().create();
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await plugins.smartfile.memory.toFs(buffer, filePath);
|
||||
await plugins.smartfsInstance.file(filePath).write(buffer);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,11 @@ export { crypto,fs, path, };
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartcrypto from '@push.rocks/smartcrypto';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
const smartfsInstance = new smartfs.SmartFs(new smartfs.SmartFsProviderNode());
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
|
||||
export { qenv, smartcrypto, smartfile, smartpath, smartrequest, smartshell, };
|
||||
export { qenv, smartcrypto, smartfile, smartfs, smartfsInstance, smartnetwork, smartpath, smartrequest, smartshell, };
|
||||
270
ts_tapbundle_serverside/readme.md
Normal file
270
ts_tapbundle_serverside/readme.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# @git.zone/tstest/tapbundle_serverside
|
||||
|
||||
> 🔧 Server-side testing utilities for Node.js runtime tests
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# tapbundle_serverside is included as part of @git.zone/tstest
|
||||
pnpm install --save-dev @git.zone/tstest
|
||||
```
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Overview
|
||||
|
||||
`@git.zone/tstest/tapbundle_serverside` provides server-side testing utilities exclusively for Node.js runtime. These tools make it trivial to spin up test infrastructure — free ports, HTTPS certificates, ephemeral databases, S3 storage, shell commands, and environment variable management.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🌐 **Network Utilities** — Find free ports and port ranges for test servers
|
||||
- 🔒 **HTTPS Certificates** — Generate self-signed certificates for testing
|
||||
- 💻 **Shell Commands** — Execute bash commands during tests
|
||||
- 🔐 **Environment Variables** — On-demand environment variable loading with qenv
|
||||
- 🗄️ **MongoDB Testing** — Create ephemeral MongoDB instances
|
||||
- 📦 **S3 Storage Testing** — Create local S3-compatible storage
|
||||
- 📁 **Test File Management** — Download and manage test assets
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
tap.test('should start server on free port', async () => {
|
||||
const port = await tapNodeTools.findFreePort();
|
||||
// start your server on `port`
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### tapNodeTools
|
||||
|
||||
The main singleton instance providing all Node.js-specific utilities.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Network Utilities
|
||||
|
||||
#### `findFreePort(options?)`
|
||||
|
||||
Find a single free port on the local machine.
|
||||
|
||||
```typescript
|
||||
// Default: random free port in range 3000–60000
|
||||
const port = await tapNodeTools.findFreePort();
|
||||
|
||||
// Custom range
|
||||
const port = await tapNodeTools.findFreePort({
|
||||
startPort: 8000,
|
||||
endPort: 9000,
|
||||
});
|
||||
|
||||
// With exclusions and sequential scan
|
||||
const port = await tapNodeTools.findFreePort({
|
||||
startPort: 3000,
|
||||
endPort: 60000,
|
||||
randomize: false, // default: true
|
||||
exclude: [8080, 8443],
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `startPort` | `number` | `3000` | Start of port range (inclusive) |
|
||||
| `endPort` | `number` | `60000` | End of port range (inclusive) |
|
||||
| `randomize` | `boolean` | `true` | Pick a random port vs first available |
|
||||
| `exclude` | `number[]` | `[]` | Ports to skip |
|
||||
|
||||
**Returns:** `Promise<number>` — Throws if no free port is found.
|
||||
|
||||
#### `findFreePorts(count, options?)`
|
||||
|
||||
Find multiple distinct free ports. Each found port is automatically excluded from subsequent searches, guaranteeing all returned ports are unique.
|
||||
|
||||
```typescript
|
||||
const [httpPort, wsPort, adminPort] = await tapNodeTools.findFreePorts(3);
|
||||
|
||||
// With custom range
|
||||
const ports = await tapNodeTools.findFreePorts(2, {
|
||||
startPort: 10000,
|
||||
endPort: 20000,
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `count` — Number of ports to find
|
||||
- `options` — Same as `findFreePort()`
|
||||
|
||||
**Returns:** `Promise<number[]>` — Array of distinct free ports.
|
||||
|
||||
#### `findFreePortRange(count, options?)`
|
||||
|
||||
Find consecutive free ports (e.g., `[4000, 4001, 4002]`). Useful when you need a contiguous block.
|
||||
|
||||
```typescript
|
||||
const ports = await tapNodeTools.findFreePortRange(3, {
|
||||
startPort: 20000,
|
||||
endPort: 30000,
|
||||
});
|
||||
// ports = [N, N+1, N+2] where all three are free
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `startPort` | `number` | `3000` | Start of search range |
|
||||
| `endPort` | `number` | `60000` | End of search range |
|
||||
| `exclude` | `number[]` | `[]` | Ports to skip |
|
||||
|
||||
**Returns:** `Promise<number[]>` — Array of consecutive free ports.
|
||||
|
||||
---
|
||||
|
||||
### 🔒 HTTPS Certificates
|
||||
|
||||
#### `createHttpsCert(commonName?, allowSelfSigned?)`
|
||||
|
||||
Generate a self-signed HTTPS certificate for testing secure connections.
|
||||
|
||||
```typescript
|
||||
const { key, cert } = await tapNodeTools.createHttpsCert('localhost', true);
|
||||
|
||||
// Use with Node.js https module
|
||||
const server = https.createServer({ key, cert }, handler);
|
||||
server.listen(port);
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `commonName` (default: `'localhost'`) — Certificate common name
|
||||
- `allowSelfSigned` (default: `true`) — Sets `NODE_TLS_REJECT_UNAUTHORIZED=0`
|
||||
|
||||
**Returns:** `Promise<{ key: string; cert: string }>` — PEM-encoded key and certificate.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Shell Commands
|
||||
|
||||
#### `runCommand(command)`
|
||||
|
||||
Execute a bash command and return the result.
|
||||
|
||||
```typescript
|
||||
const result = await tapNodeTools.runCommand('ls -la');
|
||||
console.log(result.exitCode);
|
||||
console.log(result.stdout);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔐 Environment Variables
|
||||
|
||||
#### `getQenv()`
|
||||
|
||||
Get the qenv instance for managing environment variables from `.nogit/` directory.
|
||||
|
||||
```typescript
|
||||
const qenv = await tapNodeTools.getQenv();
|
||||
```
|
||||
|
||||
#### `getEnvVarOnDemand(envVarName)`
|
||||
|
||||
Request an environment variable. If not available, qenv will prompt for it and store it securely.
|
||||
|
||||
```typescript
|
||||
const apiKey = await tapNodeTools.getEnvVarOnDemand('GITHUB_API_KEY');
|
||||
// If not set, prompts interactively and stores in .nogit/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🗄️ Database Testing
|
||||
|
||||
#### `createSmartmongo()`
|
||||
|
||||
Create an ephemeral MongoDB instance. Automatically started and ready to use.
|
||||
|
||||
```typescript
|
||||
const mongo = await tapNodeTools.createSmartmongo();
|
||||
// ... run database tests ...
|
||||
await mongo.stop();
|
||||
```
|
||||
|
||||
Uses [@push.rocks/smartmongo](https://code.foss.global/push.rocks/smartmongo).
|
||||
|
||||
---
|
||||
|
||||
### 📦 Storage Testing
|
||||
|
||||
#### `createSmarts3()`
|
||||
|
||||
Create a local S3-compatible storage instance for testing.
|
||||
|
||||
```typescript
|
||||
const s3 = await tapNodeTools.createSmartStorage();
|
||||
// ... run storage tests ...
|
||||
await s3.stop();
|
||||
```
|
||||
|
||||
Default config: port 3003, clean slate enabled. Uses [@push.rocks/smartstorage](https://code.foss.global/push.rocks/smartstorage).
|
||||
|
||||
---
|
||||
|
||||
### 📁 Test File Provider
|
||||
|
||||
#### `testFileProvider.getDockerAlpineImageAsLocalTarball()`
|
||||
|
||||
Download the Alpine Linux Docker image as a tarball for testing.
|
||||
|
||||
```typescript
|
||||
const tarballPath = await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
|
||||
// Path: ./.nogit/testfiles/alpine.tar
|
||||
```
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
⚠️ **Node.js only.** All utilities in this module require Node.js. Import only in `.node.ts` test files.
|
||||
|
||||
```
|
||||
test/mytest.node.ts ✅ Correct — Node.js only
|
||||
test/mytest.ts ✅ Correct — defaults to Node.js
|
||||
test/mytest.all.ts ❌ Will fail in Deno/Bun/Chromium
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [@push.rocks/smartnetwork](https://code.foss.global/push.rocks/smartnetwork) — Port discovery
|
||||
- [@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/smartstorage](https://code.foss.global/push.rocks/smartstorage) — 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
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) 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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
Reference in New Issue
Block a user