Compare commits

...

18 Commits

Author SHA1 Message Date
ecf11efb4c 2.4.1 2025-10-10 16:45:06 +00:00
1de674e91d fix(runtime/deno): Enable Deno runtime tests by adding required permissions and local settings 2025-10-10 16:45:06 +00:00
9fa2c23ab2 2.4.0 2025-10-10 16:35:22 +00:00
36715c9139 feat(runtime): Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests 2025-10-10 16:35:22 +00:00
ee0aca9ff7 2.3.8 2025-09-12 18:51:28 +00:00
aaebe75326 fix(tstest): Improve free port selection for Chrome runner and bump smartnetwork dependency 2025-09-12 18:51:28 +00:00
265ed702ee 2.3.7 2025-09-12 14:09:28 +00:00
efbaded1f3 fix(tests): Remove flaky dynamic-ports browser test and add local dev tool settings 2025-09-12 14:09:28 +00:00
799a60188f feat(tstest): Implement dynamic port allocation for HTTP and WebSocket connections, add tests for port validation 2025-09-12 14:06:03 +00:00
3c38a53d9d 2.3.6 2025-09-03 12:37:57 +00:00
cca01b51ec fix(tstest): Update deps, fix chrome server route for static bundles, add local tool settings and CI ignore 2025-09-03 12:37:57 +00:00
84843ad359 2.3.5 2025-08-18 02:40:44 +00:00
7a8ae95be2 fix(core): Use SmartRequest with Buffer for binary downloads, tighten static route handling, bump dependencies and add workspace/config files 2025-08-18 02:40:44 +00:00
133e0eda8b 2.3.4 2025-08-16 18:07:57 +00:00
14e32b06de fix(ci): Add local Claude settings to allow required WebFetch and Bash permissions for local tooling and tests 2025-08-16 18:07:57 +00:00
48aebb1eac 2.3.3 2025-08-16 18:01:44 +00:00
733b2249d0 fix(dependencies): Bump dependency versions and add local Claude settings 2025-08-16 18:01:44 +00:00
008844a9e2 fix(tapbundle): Fix TypeScript IDE warning about tapTools parameter possibly being undefined 2025-07-24 22:24:52 +00:00
21 changed files with 5569 additions and 2752 deletions

1
.npmrc
View File

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

1
.serena/.gitignore vendored Normal file
View File

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

68
.serena/project.yml Normal file
View File

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

View File

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

View File

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

5947
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Bun runtime adapter
* Executes tests using the Bun runtime with native TypeScript support
*/
export class BunRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'bun';
readonly displayName: string = 'Bun';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Bun is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('bun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Bun not found. Install from: https://bun.sh/',
};
}
// Bun version is just the version number
const version = result.stdout.trim();
return {
available: true,
version: `Bun ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Bun test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
const args: string[] = ['run'];
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'bun',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Bun
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':bun', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build Bun command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

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

View File

@@ -0,0 +1,256 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type DenoOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Deno runtime adapter
* Executes tests using the Deno runtime
*/
export class DenoRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'deno';
readonly displayName: string = 'Deno';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Get default Deno options
*/
protected getDefaultOptions(): DenoOptions {
return {
...super.getDefaultOptions(),
permissions: [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--sloppy-imports', // Allow .js imports to resolve to .ts files
],
};
}
/**
* Check if Deno is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('deno --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Deno not found. Install from: https://deno.land/',
};
}
// Parse Deno version from output (first line is "deno X.Y.Z")
const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
return {
available: true,
version: `Deno ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Deno test execution
*/
createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options) as DenoOptions;
const args: string[] = ['run'];
// Add permissions
const permissions = mergedOptions.permissions || [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--sloppy-imports',
];
args.push(...permissions);
// Add config file if specified
if (mergedOptions.configPath) {
args.push('--config', mergedOptions.configPath);
}
// Add import map if specified
if (mergedOptions.importMap) {
args.push('--import-map', mergedOptions.importMap);
}
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'deno',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Deno
*/
async run(
testFile: string,
index: number,
total: number,
options?: DenoOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':deno', this.logger);
const mergedOptions = this.mergeOptions(options) as DenoOptions;
// Build Deno command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

@@ -0,0 +1,222 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Node.js runtime adapter
* Executes tests using tsrun (TypeScript runner for Node.js)
*/
export class NodeRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'node';
readonly displayName: string = 'Node.js';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Node.js and tsrun are available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
// Check Node.js version
const nodeVersion = process.version;
// Check if tsrun is available
const result = await this.smartshellInstance.exec('tsrun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun',
};
}
return {
available: true,
version: nodeVersion,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Node.js test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
// Build tsrun options
const args: string[] = [];
if (process.argv.includes('--web')) {
args.push('--web');
}
// Add any extra args
if (mergedOptions.extraArgs) {
args.push(...mergedOptions.extraArgs);
}
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'tsrun',
args: [testFile, ...args],
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Node.js
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':node', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build tsrun command
let tsrunOptions = '';
if (process.argv.includes('--web')) {
tsrunOptions += ' --web';
}
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
let runCommand = `tsrun ${testFile}${tsrunOptions}`;
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
// If 00init.ts exists, run it first
let loaderPath: string | null = null;
if (initFileExists) {
// Create a temporary loader file that imports both 00init.ts and the test file
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

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

View File

@@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js';
import { TsTestLogger } from './tstest.logging.js'; import { TsTestLogger } from './tstest.logging.js';
import type { LogOptions } from './tstest.logging.js'; import type { LogOptions } from './tstest.logging.js';
// Runtime adapters
import { parseTestFilename } from './tstest.classes.runtime.parser.js';
import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js';
import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js';
import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js';
import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
export class TsTest { export class TsTest {
public testDir: TestDirectory; public testDir: TestDirectory;
public executionMode: TestExecutionMode; public executionMode: TestExecutionMode;
@@ -28,6 +36,8 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle(); public tsbundleInstance = new plugins.tsbundle.TsBundle();
public runtimeRegistry = new RuntimeAdapterRegistry();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) { constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
this.executionMode = executionModeArg; this.executionMode = executionModeArg;
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg); this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
@@ -36,6 +46,20 @@ export class TsTest {
this.startFromFile = startFromFile; this.startFromFile = startFromFile;
this.stopAtFile = stopAtFile; this.stopAtFile = stopAtFile;
this.timeoutSeconds = timeoutSeconds; this.timeoutSeconds = timeoutSeconds;
// Register runtime adapters
this.runtimeRegistry.register(
new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds)
);
this.runtimeRegistry.register(
new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
} }
async run() { async run() {
@@ -175,29 +199,50 @@ export class TsTest {
} }
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
switch (true) { // Parse the filename to determine runtimes and modifiers
case process.env.CI && fileNameArg.includes('.nonci.'): const fileName = plugins.path.basename(fileNameArg);
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
break;
case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBrowser);
break;
case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
this.logger.sectionStart('Part 1: Chrome');
const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBothBrowser);
this.logger.sectionEnd();
this.logger.sectionStart('Part 2: Node'); // Check for nonci modifier in CI environment
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); if (process.env.CI && parsed.modifiers.includes('nonci')) {
tapCombinator.addTapParser(tapParserBothNode); this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
return;
}
// Show deprecation warning for legacy naming
if (parsed.isLegacy) {
console.warn('');
console.warn(cs('⚠️ DEPRECATION WARNING', 'orange'));
console.warn(cs(` File: ${fileName}`, 'orange'));
console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange'));
console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green'));
console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan'));
console.warn('');
}
// Get adapters for the specified runtimes
const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes);
if (adapters.length === 0) {
this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`);
return;
}
// Execute tests for each runtime
if (adapters.length === 1) {
// Single runtime - no sections needed
const adapter = adapters[0];
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
} else {
// Multiple runtimes - use sections
for (let i = 0; i < adapters.length; i++) {
const adapter = adapters[i];
this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
this.logger.sectionEnd(); this.logger.sectionEnd();
break; }
default:
const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserNode);
break;
} }
} }
@@ -316,6 +361,31 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
return tapParser; return tapParser;
} }
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
const smartnetwork = new plugins.smartnetwork.SmartNetwork();
// Find random free HTTP port in range 30000-40000 to minimize collision chance
const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!httpPort) {
throw new Error('Could not find a free HTTP port in range 30000-40000');
}
// Find random free WebSocket port, excluding the HTTP port to ensure they're different
const wsPort = await smartnetwork.findFreePort(30000, 40000, {
randomize: true,
exclude: [httpPort]
});
if (!wsPort) {
throw new Error('Could not find a free WebSocket port in range 30000-40000');
}
// Log selected ports for debugging
if (!this.logger.options.quiet) {
console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
}
return { httpPort, wsPort };
}
public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> { public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> {
this.logger.testFileStart(fileNameArg, 'chromium', index, total); this.logger.testFileStart(fileNameArg, 'chromium', index, total);
@@ -330,10 +400,13 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
bundler: 'esbuild', bundler: 'esbuild',
}); });
// Find free ports for HTTP and WebSocket
const { httpPort, wsPort } = await this.findFreePorts();
// lets create a server // lets create a server
const server = new plugins.typedserver.servertools.Server({ const server = new plugins.typedserver.servertools.Server({
cors: true, cors: true,
port: 3007, port: httpPort,
}); });
server.addRoute( server.addRoute(
'/test', '/test',
@@ -344,6 +417,7 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
<head> <head>
<script> <script>
globalThis.testdom = true; globalThis.testdom = true;
globalThis.wsPort = ${wsPort};
</script> </script>
</head> </head>
<body></body> <body></body>
@@ -352,12 +426,12 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
res.end(); res.end();
}) })
); );
server.addRoute('*', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath)); server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
await server.start(); await server.start();
// lets handle realtime comms // lets handle realtime comms
const tapParser = new TapParser(fileNameArg + ':chrome', this.logger); const tapParser = new TapParser(fileNameArg + ':chrome', this.logger);
const wss = new plugins.ws.WebSocketServer({ port: 8080 }); const wss = new plugins.ws.WebSocketServer({ port: wsPort });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws.on('message', (message) => { ws.on('message', (message) => {
const messageStr = message.toString(); const messageStr = message.toString();
@@ -374,10 +448,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
await this.smartbrowserInstance.start(); await this.smartbrowserInstance.start();
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage( const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:3007/test?bundleName=${bundleFileName}`, `http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
async () => { async () => {
// lets enable real time comms // lets enable real time comms
const ws = new WebSocket('ws://localhost:8080'); const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
await new Promise((resolve) => (ws.onopen = resolve)); await new Promise((resolve) => (ws.onopen = resolve));
// Ensure this function is declared with 'async' // Ensure this function is declared with 'async'

View File

@@ -17,6 +17,7 @@ import * as smartchok from '@push.rocks/smartchok';
import * as smartdelay from '@push.rocks/smartdelay'; import * as smartdelay from '@push.rocks/smartdelay';
import * as smartfile from '@push.rocks/smartfile'; import * as smartfile from '@push.rocks/smartfile';
import * as smartlog from '@push.rocks/smartlog'; import * as smartlog from '@push.rocks/smartlog';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpromise from '@push.rocks/smartpromise';
import * as smartshell from '@push.rocks/smartshell'; import * as smartshell from '@push.rocks/smartshell';
import * as tapbundle from '../dist_ts_tapbundle/index.js'; import * as tapbundle from '../dist_ts_tapbundle/index.js';
@@ -28,6 +29,7 @@ export {
smartdelay, smartdelay,
smartfile, smartfile,
smartlog, smartlog,
smartnetwork,
smartpromise, smartpromise,
smartshell, smartshell,
tapbundle, tapbundle,

View File

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

View File

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