Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
ee11b1ac17 | |||
054cbb6b3c | |||
ecf11efb4c | |||
1de674e91d | |||
9fa2c23ab2 | |||
36715c9139 | |||
ee0aca9ff7 | |||
aaebe75326 | |||
265ed702ee | |||
efbaded1f3 | |||
799a60188f | |||
3c38a53d9d | |||
cca01b51ec |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
47
changelog.md
47
changelog.md
@@ -1,5 +1,52 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-10-10 - 2.4.2 - fix(deno)
|
||||||
|
Enable additional Deno permissions for runtime adapters and add local dev settings
|
||||||
|
|
||||||
|
- Add --allow-sys, --allow-import and --node-modules-dir to the default Deno permission set used by the Deno runtime adapter
|
||||||
|
- Include the new permission flags in the fallback permissions array when constructing Deno command args
|
||||||
|
- Add .claude/settings.local.json to capture local development permissions and helper commands
|
||||||
|
|
||||||
|
## 2025-10-10 - 2.4.1 - fix(runtime/deno)
|
||||||
|
Enable Deno runtime tests by adding required permissions and local settings
|
||||||
|
|
||||||
|
- ts/tstest.classes.runtime.deno.ts: expanded default Deno permissions to include --allow-net, --allow-write and --sloppy-imports to allow network access, file writes and permissive JS/TS imports
|
||||||
|
- ts/tstest.classes.runtime.deno.ts: updated fallback permissions used when building the Deno command to match the new default set
|
||||||
|
- Added .claude/settings.local.json with a set of allowed local commands/permissions used for local development/CI tooling
|
||||||
|
|
||||||
|
## 2025-10-10 - 2.4.0 - feat(runtime)
|
||||||
|
Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests
|
||||||
|
|
||||||
|
- Introduce RuntimeAdapter abstraction and RuntimeAdapterRegistry to manage multiple runtimes
|
||||||
|
- Add runtime adapters: NodeRuntimeAdapter, ChromiumRuntimeAdapter, DenoRuntimeAdapter and BunRuntimeAdapter
|
||||||
|
- Add filename runtime parser utilities: parseTestFilename, isLegacyFilename and getLegacyMigrationTarget
|
||||||
|
- Add Migration class to detect and (dry-run) migrate legacy test filenames to the new naming convention
|
||||||
|
- Integrate runtime registry into TsTest and choose execution adapters based on parsed runtimes; show deprecation warnings for legacy naming
|
||||||
|
- Add tests covering runtime parsing and migration: test/test.runtime.parser.node.ts and test/test.migration.node.ts
|
||||||
|
|
||||||
|
## 2025-09-12 - 2.3.8 - fix(tstest)
|
||||||
|
Improve free port selection for Chrome runner and bump smartnetwork dependency
|
||||||
|
|
||||||
|
- Use randomized port selection when finding free HTTP and WebSocket ports to reduce collision probability in concurrent runs
|
||||||
|
- Ensure WebSocket port search excludes the chosen HTTP port so the two ports will not conflict
|
||||||
|
- Simplify failure handling: throw early if a free WebSocket port cannot be found instead of retrying with a less robust fallback
|
||||||
|
- Bump @push.rocks/smartnetwork dependency from ^4.2.0 to ^4.4.0 to pick up new findFreePort options
|
||||||
|
|
||||||
|
## 2025-09-12 - 2.3.7 - fix(tests)
|
||||||
|
Remove flaky dynamic-ports browser test and add local dev tool settings
|
||||||
|
|
||||||
|
- Removed test/tapbundle/test.dynamicports.ts — deletes a browser test that relied on injected dynamic WebSocket ports (reduces flaky CI/browser runs).
|
||||||
|
- Added .claude/settings.local.json — local development settings for the CLAUDE helper (grants allowed dev/automation commands and webfetch permissions).
|
||||||
|
|
||||||
|
## 2025-09-03 - 2.3.6 - fix(tstest)
|
||||||
|
Update deps, fix chrome server route for static bundles, add local tool settings and CI ignore
|
||||||
|
|
||||||
|
- Bump devDependency @git.zone/tsbuild to ^2.6.8
|
||||||
|
- Bump dependencies: @api.global/typedserver to ^3.0.78, @push.rocks/smartlog to ^3.1.9, @push.rocks/smartrequest to ^4.3.1
|
||||||
|
- Fix test server static route in ts/tstest.classes.tstest.ts: replace '(.*)' with '/*splat' so bundled test files are served correctly in Chromium runs
|
||||||
|
- Add .claude/settings.local.json with local permissions for development tasks
|
||||||
|
- Add .serena/.gitignore to ignore /cache
|
||||||
|
|
||||||
## 2025-08-18 - 2.3.5 - fix(core)
|
## 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
|
Use SmartRequest with Buffer for binary downloads, tighten static route handling, bump dependencies and add workspace/config files
|
||||||
|
|
||||||
|
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tstest",
|
"name": "@git.zone/tstest",
|
||||||
"version": "2.3.5",
|
"version": "2.4.2",
|
||||||
"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,11 +24,11 @@
|
|||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.7",
|
"@git.zone/tsbuild": "^2.6.8",
|
||||||
"@types/node": "^22.15.21"
|
"@types/node": "^22.15.21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedserver": "^3.0.77",
|
"@api.global/typedserver": "^3.0.78",
|
||||||
"@git.zone/tsbundle": "^2.5.1",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@push.rocks/consolecolor": "^2.0.3",
|
"@push.rocks/consolecolor": "^2.0.3",
|
||||||
@@ -41,11 +41,12 @@
|
|||||||
"@push.rocks/smartexpect": "^2.5.0",
|
"@push.rocks/smartexpect": "^2.5.0",
|
||||||
"@push.rocks/smartfile": "^11.2.7",
|
"@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/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^4.2.2",
|
"@push.rocks/smartrequest": "^4.3.1",
|
||||||
"@push.rocks/smarts3": "^2.2.6",
|
"@push.rocks/smarts3": "^2.2.6",
|
||||||
"@push.rocks/smartshell": "^3.3.0",
|
"@push.rocks/smartshell": "^3.3.0",
|
||||||
"@push.rocks/smarttime": "^4.1.1",
|
"@push.rocks/smarttime": "^4.1.1",
|
||||||
|
1164
pnpm-lock.yaml
generated
1164
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
111
test/test.migration.node.ts
Normal file
111
test/test.migration.node.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { expect, tap } from '../ts_tapbundle/index.js';
|
||||||
|
import { Migration } from '../ts/tstest.classes.migration.js';
|
||||||
|
import * as plugins from '../ts/tstest.plugins.js';
|
||||||
|
import * as paths from '../ts/tstest.paths.js';
|
||||||
|
|
||||||
|
tap.test('Migration - can initialize', async () => {
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: process.cwd(),
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(migration).toBeInstanceOf(Migration);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Migration - findLegacyFiles returns empty for no legacy files', async () => {
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: process.cwd(),
|
||||||
|
pattern: 'test/test.migration.node.ts', // This file itself, not legacy
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyFiles = await migration.findLegacyFiles();
|
||||||
|
expect(legacyFiles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Migration - generateReport works', async () => {
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: process.cwd(),
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await migration.generateReport();
|
||||||
|
expect(report).toBeTypeOf('string');
|
||||||
|
expect(report).toContain('Test File Migration Report');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Migration - detects legacy files when they exist', async () => {
|
||||||
|
// Create a temporary legacy test file
|
||||||
|
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration');
|
||||||
|
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
|
||||||
|
|
||||||
|
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
|
||||||
|
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
|
||||||
|
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: tempDir,
|
||||||
|
pattern: '**/*.ts',
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyFiles = await migration.findLegacyFiles();
|
||||||
|
expect(legacyFiles.length).toEqual(1);
|
||||||
|
expect(legacyFiles[0]).toContain('test.browser.ts');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await plugins.smartfile.fs.removeSync(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Migration - detects both legacy pattern', async () => {
|
||||||
|
// Create temporary legacy files
|
||||||
|
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_both');
|
||||||
|
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
|
||||||
|
|
||||||
|
const browserFile = plugins.path.join(tempDir, 'test.browser.ts');
|
||||||
|
const bothFile = plugins.path.join(tempDir, 'test.both.ts');
|
||||||
|
await plugins.smartfile.memory.toFs('// Browser test\nexport default Promise.resolve();', browserFile);
|
||||||
|
await plugins.smartfile.memory.toFs('// Both test\nexport default Promise.resolve();', bothFile);
|
||||||
|
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: tempDir,
|
||||||
|
pattern: '**/*.ts',
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyFiles = await migration.findLegacyFiles();
|
||||||
|
expect(legacyFiles.length).toEqual(2);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await plugins.smartfile.fs.removeSync(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Migration - dry run does not modify files', async () => {
|
||||||
|
// Create a temporary legacy test file
|
||||||
|
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_dryrun');
|
||||||
|
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
|
||||||
|
|
||||||
|
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
|
||||||
|
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
|
||||||
|
|
||||||
|
const migration = new Migration({
|
||||||
|
baseDir: tempDir,
|
||||||
|
pattern: '**/*.ts',
|
||||||
|
dryRun: true,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = await migration.run();
|
||||||
|
|
||||||
|
expect(summary.dryRun).toEqual(true);
|
||||||
|
expect(summary.totalLegacyFiles).toEqual(1);
|
||||||
|
expect(summary.migratedCount).toEqual(1); // Dry run still counts as "would migrate"
|
||||||
|
|
||||||
|
// Verify original file still exists
|
||||||
|
const fileExists = await plugins.smartfile.fs.fileExists(legacyFile);
|
||||||
|
expect(fileExists).toEqual(true);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await plugins.smartfile.fs.removeSync(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
167
test/test.runtime.parser.node.ts
Normal file
167
test/test.runtime.parser.node.ts
Normal 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();
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tstest',
|
name: '@git.zone/tstest',
|
||||||
version: '2.3.5',
|
version: '2.4.2',
|
||||||
description: 'a test utility to run tests that match test/**/*.ts'
|
description: 'a test utility to run tests that match test/**/*.ts'
|
||||||
}
|
}
|
||||||
|
316
ts/tstest.classes.migration.ts
Normal file
316
ts/tstest.classes.migration.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
import { parseTestFilename, getLegacyMigrationTarget, isLegacyFilename } from './tstest.classes.runtime.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration result for a single file
|
||||||
|
*/
|
||||||
|
export interface MigrationResult {
|
||||||
|
/**
|
||||||
|
* Original file path
|
||||||
|
*/
|
||||||
|
oldPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New file path after migration
|
||||||
|
*/
|
||||||
|
newPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the migration was performed
|
||||||
|
*/
|
||||||
|
migrated: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message if migration failed
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration summary
|
||||||
|
*/
|
||||||
|
export interface MigrationSummary {
|
||||||
|
/**
|
||||||
|
* Total number of legacy files found
|
||||||
|
*/
|
||||||
|
totalLegacyFiles: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of files successfully migrated
|
||||||
|
*/
|
||||||
|
migratedCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of files that failed to migrate
|
||||||
|
*/
|
||||||
|
errorCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual migration results
|
||||||
|
*/
|
||||||
|
results: MigrationResult[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this was a dry run
|
||||||
|
*/
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration options
|
||||||
|
*/
|
||||||
|
export interface MigrationOptions {
|
||||||
|
/**
|
||||||
|
* Base directory to search for test files
|
||||||
|
* Default: process.cwd()
|
||||||
|
*/
|
||||||
|
baseDir?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glob pattern for finding test files
|
||||||
|
* Default: '** /*test*.ts' (without space)
|
||||||
|
*/
|
||||||
|
pattern?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry run mode - don't actually rename files
|
||||||
|
* Default: true
|
||||||
|
*/
|
||||||
|
dryRun?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbose output
|
||||||
|
* Default: false
|
||||||
|
*/
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration class for renaming legacy test files to new naming convention
|
||||||
|
*
|
||||||
|
* Migrations:
|
||||||
|
* - .browser.ts → .chromium.ts
|
||||||
|
* - .both.ts → .node+chromium.ts
|
||||||
|
* - .both.nonci.ts → .node+chromium.nonci.ts
|
||||||
|
* - .browser.nonci.ts → .chromium.nonci.ts
|
||||||
|
*/
|
||||||
|
export class Migration {
|
||||||
|
private options: Required<MigrationOptions>;
|
||||||
|
|
||||||
|
constructor(options: MigrationOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
baseDir: options.baseDir || process.cwd(),
|
||||||
|
pattern: options.pattern || '**/test*.ts',
|
||||||
|
dryRun: options.dryRun !== undefined ? options.dryRun : true,
|
||||||
|
verbose: options.verbose || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all legacy test files in the base directory
|
||||||
|
*/
|
||||||
|
async findLegacyFiles(): Promise<string[]> {
|
||||||
|
const files = await plugins.smartfile.fs.listFileTree(
|
||||||
|
this.options.baseDir,
|
||||||
|
this.options.pattern
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fileName = plugins.path.basename(file);
|
||||||
|
if (isLegacyFilename(fileName)) {
|
||||||
|
const absolutePath = plugins.path.isAbsolute(file)
|
||||||
|
? file
|
||||||
|
: plugins.path.join(this.options.baseDir, file);
|
||||||
|
legacyFiles.push(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return legacyFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a single file
|
||||||
|
*/
|
||||||
|
private async migrateFile(filePath: string): Promise<MigrationResult> {
|
||||||
|
const fileName = plugins.path.basename(filePath);
|
||||||
|
const dirName = plugins.path.dirname(filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the new filename
|
||||||
|
const newFileName = getLegacyMigrationTarget(fileName);
|
||||||
|
|
||||||
|
if (!newFileName) {
|
||||||
|
return {
|
||||||
|
oldPath: filePath,
|
||||||
|
newPath: filePath,
|
||||||
|
migrated: false,
|
||||||
|
error: 'File is not a legacy file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPath = plugins.path.join(dirName, newFileName);
|
||||||
|
|
||||||
|
// Check if target file already exists
|
||||||
|
if (await plugins.smartfile.fs.fileExists(newPath)) {
|
||||||
|
return {
|
||||||
|
oldPath: filePath,
|
||||||
|
newPath,
|
||||||
|
migrated: false,
|
||||||
|
error: `Target file already exists: ${newPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.dryRun) {
|
||||||
|
// Check if we're in a git repository
|
||||||
|
const isGitRepo = await this.isGitRepository(this.options.baseDir);
|
||||||
|
|
||||||
|
if (isGitRepo) {
|
||||||
|
// Use git mv to preserve history
|
||||||
|
const smartshell = new plugins.smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
pathDirectories: [],
|
||||||
|
});
|
||||||
|
const gitCommand = `cd "${this.options.baseDir}" && git mv "${filePath}" "${newPath}"`;
|
||||||
|
const result = await smartshell.exec(gitCommand);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`git mv failed: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not a git repository - cannot migrate without git
|
||||||
|
throw new Error('Migration requires a git repository. We have git!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldPath: filePath,
|
||||||
|
newPath,
|
||||||
|
migrated: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
oldPath: filePath,
|
||||||
|
newPath: filePath,
|
||||||
|
migrated: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a directory is a git repository
|
||||||
|
*/
|
||||||
|
private async isGitRepository(dir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const gitDir = plugins.path.join(dir, '.git');
|
||||||
|
return await plugins.smartfile.fs.isDirectory(gitDir);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration
|
||||||
|
*/
|
||||||
|
async run(): Promise<MigrationSummary> {
|
||||||
|
const legacyFiles = await this.findLegacyFiles();
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(cs('='.repeat(60), 'blue'));
|
||||||
|
console.log(cs('Test File Migration Tool', 'blue'));
|
||||||
|
console.log(cs('='.repeat(60), 'blue'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (this.options.dryRun) {
|
||||||
|
console.log(cs('🔍 DRY RUN MODE - No files will be modified', 'orange'));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${legacyFiles.length} legacy test file(s)`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const results: MigrationResult[] = [];
|
||||||
|
let migratedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const file of legacyFiles) {
|
||||||
|
const result = await this.migrateFile(file);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
if (result.migrated) {
|
||||||
|
migratedCount++;
|
||||||
|
const oldName = plugins.path.basename(result.oldPath);
|
||||||
|
const newName = plugins.path.basename(result.newPath);
|
||||||
|
|
||||||
|
if (this.options.dryRun) {
|
||||||
|
console.log(cs(` Would migrate:`, 'cyan'));
|
||||||
|
} else {
|
||||||
|
console.log(cs(` ✓ Migrated:`, 'green'));
|
||||||
|
}
|
||||||
|
console.log(` ${oldName}`);
|
||||||
|
console.log(cs(` → ${newName}`, 'green'));
|
||||||
|
console.log('');
|
||||||
|
} else if (result.error) {
|
||||||
|
errorCount++;
|
||||||
|
console.log(cs(` ✗ Failed: ${plugins.path.basename(result.oldPath)}`, 'red'));
|
||||||
|
console.log(cs(` ${result.error}`, 'red'));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(cs('='.repeat(60), 'blue'));
|
||||||
|
console.log(`Summary:`);
|
||||||
|
console.log(` Total legacy files: ${legacyFiles.length}`);
|
||||||
|
console.log(` Successfully migrated: ${migratedCount}`);
|
||||||
|
console.log(` Errors: ${errorCount}`);
|
||||||
|
console.log(cs('='.repeat(60), 'blue'));
|
||||||
|
|
||||||
|
if (this.options.dryRun && legacyFiles.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(cs('To apply these changes, run:', 'orange'));
|
||||||
|
console.log(cs(' tstest migrate --write', 'orange'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLegacyFiles: legacyFiles.length,
|
||||||
|
migratedCount,
|
||||||
|
errorCount,
|
||||||
|
results,
|
||||||
|
dryRun: this.options.dryRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a migration report without performing the migration
|
||||||
|
*/
|
||||||
|
async generateReport(): Promise<string> {
|
||||||
|
const legacyFiles = await this.findLegacyFiles();
|
||||||
|
|
||||||
|
let report = '';
|
||||||
|
report += 'Test File Migration Report\n';
|
||||||
|
report += '='.repeat(60) + '\n';
|
||||||
|
report += '\n';
|
||||||
|
report += `Found ${legacyFiles.length} legacy test file(s)\n`;
|
||||||
|
report += '\n';
|
||||||
|
|
||||||
|
for (const file of legacyFiles) {
|
||||||
|
const fileName = plugins.path.basename(file);
|
||||||
|
const newFileName = getLegacyMigrationTarget(fileName);
|
||||||
|
|
||||||
|
if (newFileName) {
|
||||||
|
report += `${fileName}\n`;
|
||||||
|
report += ` → ${newFileName}\n`;
|
||||||
|
report += '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report += '='.repeat(60) + '\n';
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
245
ts/tstest.classes.runtime.adapter.ts
Normal file
245
ts/tstest.classes.runtime.adapter.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||||
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-specific configuration options
|
||||||
|
*/
|
||||||
|
export interface RuntimeOptions {
|
||||||
|
/**
|
||||||
|
* Environment variables to pass to the runtime
|
||||||
|
*/
|
||||||
|
env?: Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional command-line arguments
|
||||||
|
*/
|
||||||
|
extraArgs?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Working directory for test execution
|
||||||
|
*/
|
||||||
|
cwd?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout in milliseconds (0 = no timeout)
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deno-specific configuration options
|
||||||
|
*/
|
||||||
|
export interface DenoOptions extends RuntimeOptions {
|
||||||
|
/**
|
||||||
|
* Permissions to grant to Deno
|
||||||
|
* Default: ['--allow-read', '--allow-env']
|
||||||
|
*/
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to deno.json config file
|
||||||
|
*/
|
||||||
|
configPath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to import map file
|
||||||
|
*/
|
||||||
|
importMap?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium-specific configuration options
|
||||||
|
*/
|
||||||
|
export interface ChromiumOptions extends RuntimeOptions {
|
||||||
|
/**
|
||||||
|
* Chromium launch arguments
|
||||||
|
*/
|
||||||
|
launchArgs?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless mode (default: true)
|
||||||
|
*/
|
||||||
|
headless?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port range for HTTP server
|
||||||
|
*/
|
||||||
|
portRange?: { min: number; max: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command configuration returned by createCommand()
|
||||||
|
*/
|
||||||
|
export interface RuntimeCommand {
|
||||||
|
/**
|
||||||
|
* The main command executable (e.g., 'node', 'deno', 'bun')
|
||||||
|
*/
|
||||||
|
command: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-line arguments
|
||||||
|
*/
|
||||||
|
args: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variables
|
||||||
|
*/
|
||||||
|
env?: Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Working directory
|
||||||
|
*/
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime availability check result
|
||||||
|
*/
|
||||||
|
export interface RuntimeAvailability {
|
||||||
|
/**
|
||||||
|
* Whether the runtime is available
|
||||||
|
*/
|
||||||
|
available: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version string if available
|
||||||
|
*/
|
||||||
|
version?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message if not available
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for runtime adapters
|
||||||
|
* Each runtime (Node, Chromium, Deno, Bun) implements this interface
|
||||||
|
*/
|
||||||
|
export abstract class RuntimeAdapter {
|
||||||
|
/**
|
||||||
|
* Runtime identifier
|
||||||
|
*/
|
||||||
|
abstract readonly id: Runtime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable display name
|
||||||
|
*/
|
||||||
|
abstract readonly displayName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this runtime is available on the system
|
||||||
|
* @returns Availability information including version
|
||||||
|
*/
|
||||||
|
abstract checkAvailable(): Promise<RuntimeAvailability>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the command configuration for executing a test
|
||||||
|
* @param testFile - Absolute path to the test file
|
||||||
|
* @param options - Runtime-specific options
|
||||||
|
* @returns Command configuration
|
||||||
|
*/
|
||||||
|
abstract createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a test file and return a TAP parser
|
||||||
|
* @param testFile - Absolute path to the test file
|
||||||
|
* @param index - Test index (for display)
|
||||||
|
* @param total - Total number of tests (for display)
|
||||||
|
* @param options - Runtime-specific options
|
||||||
|
* @returns TAP parser with test results
|
||||||
|
*/
|
||||||
|
abstract run(
|
||||||
|
testFile: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
options?: RuntimeOptions
|
||||||
|
): Promise<TapParser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default options for this runtime
|
||||||
|
* Can be overridden by subclasses
|
||||||
|
*/
|
||||||
|
protected getDefaultOptions(): RuntimeOptions {
|
||||||
|
return {
|
||||||
|
timeout: 0,
|
||||||
|
extraArgs: [],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge user options with defaults
|
||||||
|
*/
|
||||||
|
protected mergeOptions<T extends RuntimeOptions>(userOptions?: T): T {
|
||||||
|
const defaults = this.getDefaultOptions();
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...userOptions,
|
||||||
|
env: { ...defaults.env, ...userOptions?.env },
|
||||||
|
extraArgs: [...(defaults.extraArgs || []), ...(userOptions?.extraArgs || [])],
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for runtime adapters
|
||||||
|
* Manages all available runtime implementations
|
||||||
|
*/
|
||||||
|
export class RuntimeAdapterRegistry {
|
||||||
|
private adapters: Map<Runtime, RuntimeAdapter> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a runtime adapter
|
||||||
|
*/
|
||||||
|
register(adapter: RuntimeAdapter): void {
|
||||||
|
this.adapters.set(adapter.id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an adapter by runtime ID
|
||||||
|
*/
|
||||||
|
get(runtime: Runtime): RuntimeAdapter | undefined {
|
||||||
|
return this.adapters.get(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered adapters
|
||||||
|
*/
|
||||||
|
getAll(): RuntimeAdapter[] {
|
||||||
|
return Array.from(this.adapters.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which runtimes are available on the system
|
||||||
|
*/
|
||||||
|
async checkAvailability(): Promise<Map<Runtime, RuntimeAvailability>> {
|
||||||
|
const results = new Map<Runtime, RuntimeAvailability>();
|
||||||
|
|
||||||
|
for (const [runtime, adapter] of this.adapters) {
|
||||||
|
const availability = await adapter.checkAvailable();
|
||||||
|
results.set(runtime, availability);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get adapters for a list of runtimes, in order
|
||||||
|
* @param runtimes - Ordered list of runtimes
|
||||||
|
* @returns Adapters in the same order, skipping any that aren't registered
|
||||||
|
*/
|
||||||
|
getAdaptersForRuntimes(runtimes: Runtime[]): RuntimeAdapter[] {
|
||||||
|
const adapters: RuntimeAdapter[] = [];
|
||||||
|
|
||||||
|
for (const runtime of runtimes) {
|
||||||
|
const adapter = this.get(runtime);
|
||||||
|
if (adapter) {
|
||||||
|
adapters.push(adapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapters;
|
||||||
|
}
|
||||||
|
}
|
219
ts/tstest.classes.runtime.bun.ts
Normal file
219
ts/tstest.classes.runtime.bun.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
import {
|
||||||
|
RuntimeAdapter,
|
||||||
|
type RuntimeOptions,
|
||||||
|
type RuntimeCommand,
|
||||||
|
type RuntimeAvailability,
|
||||||
|
} from './tstest.classes.runtime.adapter.js';
|
||||||
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||||
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
|
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bun runtime adapter
|
||||||
|
* Executes tests using the Bun runtime with native TypeScript support
|
||||||
|
*/
|
||||||
|
export class BunRuntimeAdapter extends RuntimeAdapter {
|
||||||
|
readonly id: Runtime = 'bun';
|
||||||
|
readonly displayName: string = 'Bun';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logger: TsTestLogger,
|
||||||
|
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
|
||||||
|
private timeoutSeconds: number | null,
|
||||||
|
private filterTags: string[]
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Bun is available
|
||||||
|
*/
|
||||||
|
async checkAvailable(): Promise<RuntimeAvailability> {
|
||||||
|
try {
|
||||||
|
const result = await this.smartshellInstance.exec('bun --version', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
onError: () => {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: 'Bun not found. Install from: https://bun.sh/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bun version is just the version number
|
||||||
|
const version = result.stdout.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
version: `Bun ${version}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create command configuration for Bun test execution
|
||||||
|
*/
|
||||||
|
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
|
||||||
|
const mergedOptions = this.mergeOptions(options);
|
||||||
|
|
||||||
|
const args: string[] = ['run'];
|
||||||
|
|
||||||
|
// Add extra args
|
||||||
|
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
|
||||||
|
args.push(...mergedOptions.extraArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add test file
|
||||||
|
args.push(testFile);
|
||||||
|
|
||||||
|
// Set environment variables
|
||||||
|
const env = { ...mergedOptions.env };
|
||||||
|
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'bun',
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
cwd: mergedOptions.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a test file in Bun
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
testFile: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
options?: RuntimeOptions
|
||||||
|
): Promise<TapParser> {
|
||||||
|
this.logger.testFileStart(testFile, this.displayName, index, total);
|
||||||
|
const tapParser = new TapParser(testFile + ':bun', this.logger);
|
||||||
|
|
||||||
|
const mergedOptions = this.mergeOptions(options);
|
||||||
|
|
||||||
|
// Build Bun command
|
||||||
|
const command = this.createCommand(testFile, mergedOptions);
|
||||||
|
const fullCommand = `${command.command} ${command.args.join(' ')}`;
|
||||||
|
|
||||||
|
// Set filter tags as environment variable
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 00init.ts file in test directory
|
||||||
|
const testDir = plugins.path.dirname(testFile);
|
||||||
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||||
|
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
|
||||||
|
|
||||||
|
let runCommand = fullCommand;
|
||||||
|
let loaderPath: string | null = null;
|
||||||
|
|
||||||
|
// If 00init.ts exists, create a loader file
|
||||||
|
if (initFileExists) {
|
||||||
|
const absoluteInitFile = plugins.path.resolve(initFile);
|
||||||
|
const absoluteTestFile = plugins.path.resolve(testFile);
|
||||||
|
const loaderContent = `
|
||||||
|
import '${absoluteInitFile.replace(/\\/g, '/')}';
|
||||||
|
import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||||
|
`;
|
||||||
|
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
|
||||||
|
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
|
||||||
|
|
||||||
|
// Rebuild command with loader file
|
||||||
|
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
|
||||||
|
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
|
||||||
|
|
||||||
|
// If we created a loader file, clean it up after test execution
|
||||||
|
if (loaderPath) {
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||||
|
plugins.smartfile.fs.removeSync(loaderPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execResultStreaming.childProcess.on('exit', cleanup);
|
||||||
|
execResultStreaming.childProcess.on('error', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start warning timer if no timeout was specified
|
||||||
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${testFile}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout if specified
|
||||||
|
if (this.timeoutSeconds !== null) {
|
||||||
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(async () => {
|
||||||
|
// Use smartshell's terminate() to kill entire process tree
|
||||||
|
await execResultStreaming.terminate();
|
||||||
|
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
tapParser.handleTapProcess(execResultStreaming.childProcess),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
// Clear timeout if test completed successfully
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// Handle timeout error
|
||||||
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
|
// Ensure entire process tree is killed if still running
|
||||||
|
try {
|
||||||
|
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
|
||||||
|
} catch (killError) {
|
||||||
|
// Process tree might already be dead
|
||||||
|
}
|
||||||
|
await tapParser.evaluateFinalResult();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tapParser;
|
||||||
|
}
|
||||||
|
}
|
293
ts/tstest.classes.runtime.chromium.ts
Normal file
293
ts/tstest.classes.runtime.chromium.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import * as paths from './tstest.paths.js';
|
||||||
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
import {
|
||||||
|
RuntimeAdapter,
|
||||||
|
type ChromiumOptions,
|
||||||
|
type RuntimeCommand,
|
||||||
|
type RuntimeAvailability,
|
||||||
|
} from './tstest.classes.runtime.adapter.js';
|
||||||
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||||
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
|
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium runtime adapter
|
||||||
|
* Executes tests in a headless Chromium browser
|
||||||
|
*/
|
||||||
|
export class ChromiumRuntimeAdapter extends RuntimeAdapter {
|
||||||
|
readonly id: Runtime = 'chromium';
|
||||||
|
readonly displayName: string = 'Chromium';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logger: TsTestLogger,
|
||||||
|
private tsbundleInstance: any, // TsBundle instance from @push.rocks/tsbundle
|
||||||
|
private smartbrowserInstance: any, // SmartBrowser instance from @push.rocks/smartbrowser
|
||||||
|
private timeoutSeconds: number | null
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Chromium is available
|
||||||
|
*/
|
||||||
|
async checkAvailable(): Promise<RuntimeAvailability> {
|
||||||
|
try {
|
||||||
|
// Check if smartbrowser is available and can start
|
||||||
|
// The browser binary is usually handled by @push.rocks/smartbrowser
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
version: 'Chromium (via smartbrowser)',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: error.message || 'Chromium not available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create command configuration for Chromium test execution
|
||||||
|
* Note: Chromium tests don't use a traditional command, but this satisfies the interface
|
||||||
|
*/
|
||||||
|
createCommand(testFile: string, options?: ChromiumOptions): RuntimeCommand {
|
||||||
|
const mergedOptions = this.mergeOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'chromium',
|
||||||
|
args: [],
|
||||||
|
env: mergedOptions.env,
|
||||||
|
cwd: mergedOptions.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find free ports for HTTP server and WebSocket
|
||||||
|
*/
|
||||||
|
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
|
||||||
|
const smartnetwork = new plugins.smartnetwork.SmartNetwork();
|
||||||
|
|
||||||
|
// Find random free HTTP port in range 30000-40000 to minimize collision chance
|
||||||
|
const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
|
||||||
|
if (!httpPort) {
|
||||||
|
throw new Error('Could not find a free HTTP port in range 30000-40000');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find random free WebSocket port, excluding the HTTP port to ensure they're different
|
||||||
|
const wsPort = await smartnetwork.findFreePort(30000, 40000, {
|
||||||
|
randomize: true,
|
||||||
|
exclude: [httpPort]
|
||||||
|
});
|
||||||
|
if (!wsPort) {
|
||||||
|
throw new Error('Could not find a free WebSocket port in range 30000-40000');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log selected ports for debugging
|
||||||
|
if (!this.logger.options.quiet) {
|
||||||
|
console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
|
||||||
|
}
|
||||||
|
return { httpPort, wsPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a test file in Chromium browser
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
testFile: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
options?: ChromiumOptions
|
||||||
|
): Promise<TapParser> {
|
||||||
|
this.logger.testFileStart(testFile, this.displayName, index, total);
|
||||||
|
|
||||||
|
// lets get all our paths sorted
|
||||||
|
const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
|
||||||
|
const bundleFileName = testFile.replace('/', '__') + '.js';
|
||||||
|
const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
|
||||||
|
|
||||||
|
// lets bundle the test
|
||||||
|
await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
|
||||||
|
await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
|
||||||
|
bundler: 'esbuild',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find free ports for HTTP and WebSocket
|
||||||
|
const { httpPort, wsPort } = await this.findFreePorts();
|
||||||
|
|
||||||
|
// lets create a server
|
||||||
|
const server = new plugins.typedserver.servertools.Server({
|
||||||
|
cors: true,
|
||||||
|
port: httpPort,
|
||||||
|
});
|
||||||
|
server.addRoute(
|
||||||
|
'/test',
|
||||||
|
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
|
||||||
|
res.type('.html');
|
||||||
|
res.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
globalThis.testdom = true;
|
||||||
|
globalThis.wsPort = ${wsPort};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
res.end();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
// lets handle realtime comms
|
||||||
|
const tapParser = new TapParser(testFile + ':chrome', this.logger);
|
||||||
|
const wss = new plugins.ws.WebSocketServer({ port: wsPort });
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
const messageStr = message.toString();
|
||||||
|
if (messageStr.startsWith('console:')) {
|
||||||
|
const [, level, ...messageParts] = messageStr.split(':');
|
||||||
|
this.logger.browserConsole(messageParts.join(':'), level);
|
||||||
|
} else {
|
||||||
|
tapParser.handleTapLog(messageStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// lets do the browser bit with timeout handling
|
||||||
|
await this.smartbrowserInstance.start();
|
||||||
|
|
||||||
|
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
|
||||||
|
`http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
|
||||||
|
async () => {
|
||||||
|
// lets enable real time comms
|
||||||
|
const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
|
||||||
|
await new Promise((resolve) => (ws.onopen = resolve));
|
||||||
|
|
||||||
|
// Ensure this function is declared with 'async'
|
||||||
|
const logStore = [];
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
// Override console methods to capture the logs
|
||||||
|
console.log = (...args: any[]) => {
|
||||||
|
logStore.push(args.join(' '));
|
||||||
|
ws.send(args.join(' '));
|
||||||
|
originalLog(...args);
|
||||||
|
};
|
||||||
|
console.error = (...args: any[]) => {
|
||||||
|
logStore.push(args.join(' '));
|
||||||
|
ws.send(args.join(' '));
|
||||||
|
originalError(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bundleName = new URLSearchParams(window.location.search).get('bundleName');
|
||||||
|
originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamically import the test module
|
||||||
|
const testModule = await import(`/${bundleName}`);
|
||||||
|
if (testModule && testModule.default && testModule.default instanceof Promise) {
|
||||||
|
// Execute the exported test function
|
||||||
|
await testModule.default;
|
||||||
|
} else if (testModule && testModule.default && typeof testModule.default.then === 'function') {
|
||||||
|
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.');
|
||||||
|
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
await testModule.default;
|
||||||
|
} else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') {
|
||||||
|
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
console.log('Using globalThis.tapPromise');
|
||||||
|
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
await testModule.default;
|
||||||
|
} else {
|
||||||
|
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
console.error('Test module does not export a default promise.');
|
||||||
|
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||||
|
console.log(`We got: ${JSON.stringify(testModule)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logStore.join('\n');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start warning timer if no timeout was specified
|
||||||
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${testFile}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout if specified
|
||||||
|
if (this.timeoutSeconds !== null) {
|
||||||
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
evaluatePromise,
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
// Clear timeout if test completed successfully
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// Handle timeout error
|
||||||
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await evaluatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clean up resources, even on timeout
|
||||||
|
try {
|
||||||
|
await this.smartbrowserInstance.stop();
|
||||||
|
} catch (error) {
|
||||||
|
// Browser might already be stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.stop();
|
||||||
|
} catch (error) {
|
||||||
|
// Server might already be stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
wss.close();
|
||||||
|
} catch (error) {
|
||||||
|
// WebSocket server might already be closed
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${cs('=> ', 'blue')} Stopped ${cs(testFile, 'orange')} chromium instance and server.`
|
||||||
|
);
|
||||||
|
// Always evaluate final result (handleTimeout just sets up the test state)
|
||||||
|
await tapParser.evaluateFinalResult();
|
||||||
|
return tapParser;
|
||||||
|
}
|
||||||
|
}
|
262
ts/tstest.classes.runtime.deno.ts
Normal file
262
ts/tstest.classes.runtime.deno.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
import {
|
||||||
|
RuntimeAdapter,
|
||||||
|
type DenoOptions,
|
||||||
|
type RuntimeCommand,
|
||||||
|
type RuntimeAvailability,
|
||||||
|
} from './tstest.classes.runtime.adapter.js';
|
||||||
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||||
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
|
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deno runtime adapter
|
||||||
|
* Executes tests using the Deno runtime
|
||||||
|
*/
|
||||||
|
export class DenoRuntimeAdapter extends RuntimeAdapter {
|
||||||
|
readonly id: Runtime = 'deno';
|
||||||
|
readonly displayName: string = 'Deno';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logger: TsTestLogger,
|
||||||
|
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
|
||||||
|
private timeoutSeconds: number | null,
|
||||||
|
private filterTags: string[]
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default Deno options
|
||||||
|
*/
|
||||||
|
protected getDefaultOptions(): DenoOptions {
|
||||||
|
return {
|
||||||
|
...super.getDefaultOptions(),
|
||||||
|
permissions: [
|
||||||
|
'--allow-read',
|
||||||
|
'--allow-env',
|
||||||
|
'--allow-net',
|
||||||
|
'--allow-write',
|
||||||
|
'--allow-sys', // Allow system info access
|
||||||
|
'--allow-import', // Allow npm/node imports
|
||||||
|
'--node-modules-dir', // Enable Node.js compatibility mode
|
||||||
|
'--sloppy-imports', // Allow .js imports to resolve to .ts files
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Deno is available
|
||||||
|
*/
|
||||||
|
async checkAvailable(): Promise<RuntimeAvailability> {
|
||||||
|
try {
|
||||||
|
const result = await this.smartshellInstance.exec('deno --version', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
onError: () => {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: 'Deno not found. Install from: https://deno.land/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Deno version from output (first line is "deno X.Y.Z")
|
||||||
|
const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
|
||||||
|
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
version: `Deno ${version}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create command configuration for Deno test execution
|
||||||
|
*/
|
||||||
|
createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
|
||||||
|
const mergedOptions = this.mergeOptions(options) as DenoOptions;
|
||||||
|
|
||||||
|
const args: string[] = ['run'];
|
||||||
|
|
||||||
|
// Add permissions
|
||||||
|
const permissions = mergedOptions.permissions || [
|
||||||
|
'--allow-read',
|
||||||
|
'--allow-env',
|
||||||
|
'--allow-net',
|
||||||
|
'--allow-write',
|
||||||
|
'--allow-sys',
|
||||||
|
'--allow-import',
|
||||||
|
'--node-modules-dir',
|
||||||
|
'--sloppy-imports',
|
||||||
|
];
|
||||||
|
args.push(...permissions);
|
||||||
|
|
||||||
|
// Add config file if specified
|
||||||
|
if (mergedOptions.configPath) {
|
||||||
|
args.push('--config', mergedOptions.configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add import map if specified
|
||||||
|
if (mergedOptions.importMap) {
|
||||||
|
args.push('--import-map', mergedOptions.importMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra args
|
||||||
|
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
|
||||||
|
args.push(...mergedOptions.extraArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add test file
|
||||||
|
args.push(testFile);
|
||||||
|
|
||||||
|
// Set environment variables
|
||||||
|
const env = { ...mergedOptions.env };
|
||||||
|
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'deno',
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
cwd: mergedOptions.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a test file in Deno
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
testFile: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
options?: DenoOptions
|
||||||
|
): Promise<TapParser> {
|
||||||
|
this.logger.testFileStart(testFile, this.displayName, index, total);
|
||||||
|
const tapParser = new TapParser(testFile + ':deno', this.logger);
|
||||||
|
|
||||||
|
const mergedOptions = this.mergeOptions(options) as DenoOptions;
|
||||||
|
|
||||||
|
// Build Deno command
|
||||||
|
const command = this.createCommand(testFile, mergedOptions);
|
||||||
|
const fullCommand = `${command.command} ${command.args.join(' ')}`;
|
||||||
|
|
||||||
|
// Set filter tags as environment variable
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 00init.ts file in test directory
|
||||||
|
const testDir = plugins.path.dirname(testFile);
|
||||||
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||||
|
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
|
||||||
|
|
||||||
|
let runCommand = fullCommand;
|
||||||
|
let loaderPath: string | null = null;
|
||||||
|
|
||||||
|
// If 00init.ts exists, create a loader file
|
||||||
|
if (initFileExists) {
|
||||||
|
const absoluteInitFile = plugins.path.resolve(initFile);
|
||||||
|
const absoluteTestFile = plugins.path.resolve(testFile);
|
||||||
|
const loaderContent = `
|
||||||
|
import '${absoluteInitFile.replace(/\\/g, '/')}';
|
||||||
|
import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||||
|
`;
|
||||||
|
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
|
||||||
|
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
|
||||||
|
|
||||||
|
// Rebuild command with loader file
|
||||||
|
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
|
||||||
|
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
|
||||||
|
|
||||||
|
// If we created a loader file, clean it up after test execution
|
||||||
|
if (loaderPath) {
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||||
|
plugins.smartfile.fs.removeSync(loaderPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execResultStreaming.childProcess.on('exit', cleanup);
|
||||||
|
execResultStreaming.childProcess.on('error', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start warning timer if no timeout was specified
|
||||||
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${testFile}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout if specified
|
||||||
|
if (this.timeoutSeconds !== null) {
|
||||||
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(async () => {
|
||||||
|
// Use smartshell's terminate() to kill entire process tree
|
||||||
|
await execResultStreaming.terminate();
|
||||||
|
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
tapParser.handleTapProcess(execResultStreaming.childProcess),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
// Clear timeout if test completed successfully
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// Handle timeout error
|
||||||
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
|
// Ensure entire process tree is killed if still running
|
||||||
|
try {
|
||||||
|
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
|
||||||
|
} catch (killError) {
|
||||||
|
// Process tree might already be dead
|
||||||
|
}
|
||||||
|
await tapParser.evaluateFinalResult();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tapParser;
|
||||||
|
}
|
||||||
|
}
|
222
ts/tstest.classes.runtime.node.ts
Normal file
222
ts/tstest.classes.runtime.node.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import * as plugins from './tstest.plugins.js';
|
||||||
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
||||||
|
import {
|
||||||
|
RuntimeAdapter,
|
||||||
|
type RuntimeOptions,
|
||||||
|
type RuntimeCommand,
|
||||||
|
type RuntimeAvailability,
|
||||||
|
} from './tstest.classes.runtime.adapter.js';
|
||||||
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
||||||
|
import { TsTestLogger } from './tstest.logging.js';
|
||||||
|
import type { Runtime } from './tstest.classes.runtime.parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node.js runtime adapter
|
||||||
|
* Executes tests using tsrun (TypeScript runner for Node.js)
|
||||||
|
*/
|
||||||
|
export class NodeRuntimeAdapter extends RuntimeAdapter {
|
||||||
|
readonly id: Runtime = 'node';
|
||||||
|
readonly displayName: string = 'Node.js';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logger: TsTestLogger,
|
||||||
|
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
|
||||||
|
private timeoutSeconds: number | null,
|
||||||
|
private filterTags: string[]
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Node.js and tsrun are available
|
||||||
|
*/
|
||||||
|
async checkAvailable(): Promise<RuntimeAvailability> {
|
||||||
|
try {
|
||||||
|
// Check Node.js version
|
||||||
|
const nodeVersion = process.version;
|
||||||
|
|
||||||
|
// Check if tsrun is available
|
||||||
|
const result = await this.smartshellInstance.exec('tsrun --version', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
onError: () => {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
version: nodeVersion,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create command configuration for Node.js test execution
|
||||||
|
*/
|
||||||
|
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
|
||||||
|
const mergedOptions = this.mergeOptions(options);
|
||||||
|
|
||||||
|
// Build tsrun options
|
||||||
|
const args: string[] = [];
|
||||||
|
|
||||||
|
if (process.argv.includes('--web')) {
|
||||||
|
args.push('--web');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any extra args
|
||||||
|
if (mergedOptions.extraArgs) {
|
||||||
|
args.push(...mergedOptions.extraArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment variables
|
||||||
|
const env = { ...mergedOptions.env };
|
||||||
|
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'tsrun',
|
||||||
|
args: [testFile, ...args],
|
||||||
|
env,
|
||||||
|
cwd: mergedOptions.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a test file in Node.js
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
testFile: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
options?: RuntimeOptions
|
||||||
|
): Promise<TapParser> {
|
||||||
|
this.logger.testFileStart(testFile, this.displayName, index, total);
|
||||||
|
const tapParser = new TapParser(testFile + ':node', this.logger);
|
||||||
|
|
||||||
|
const mergedOptions = this.mergeOptions(options);
|
||||||
|
|
||||||
|
// Build tsrun command
|
||||||
|
let tsrunOptions = '';
|
||||||
|
if (process.argv.includes('--web')) {
|
||||||
|
tsrunOptions += ' --web';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set filter tags as environment variable
|
||||||
|
if (this.filterTags.length > 0) {
|
||||||
|
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 00init.ts file in test directory
|
||||||
|
const testDir = plugins.path.dirname(testFile);
|
||||||
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
||||||
|
let runCommand = `tsrun ${testFile}${tsrunOptions}`;
|
||||||
|
|
||||||
|
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
|
||||||
|
|
||||||
|
// If 00init.ts exists, run it first
|
||||||
|
let loaderPath: string | null = null;
|
||||||
|
if (initFileExists) {
|
||||||
|
// Create a temporary loader file that imports both 00init.ts and the test file
|
||||||
|
const absoluteInitFile = plugins.path.resolve(initFile);
|
||||||
|
const absoluteTestFile = plugins.path.resolve(testFile);
|
||||||
|
const loaderContent = `
|
||||||
|
import '${absoluteInitFile.replace(/\\/g, '/')}';
|
||||||
|
import '${absoluteTestFile.replace(/\\/g, '/')}';
|
||||||
|
`;
|
||||||
|
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
|
||||||
|
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
|
||||||
|
runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
|
||||||
|
|
||||||
|
// If we created a loader file, clean it up after test execution
|
||||||
|
if (loaderPath) {
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
||||||
|
plugins.smartfile.fs.removeSync(loaderPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execResultStreaming.childProcess.on('exit', cleanup);
|
||||||
|
execResultStreaming.childProcess.on('error', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start warning timer if no timeout was specified
|
||||||
|
let warningTimer: NodeJS.Timeout | null = null;
|
||||||
|
if (this.timeoutSeconds === null) {
|
||||||
|
warningTimer = setTimeout(() => {
|
||||||
|
console.error('');
|
||||||
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
||||||
|
console.error(cs(` File: ${testFile}`, 'orange'));
|
||||||
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
||||||
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
||||||
|
console.error('');
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout if specified
|
||||||
|
if (this.timeoutSeconds !== null) {
|
||||||
|
const timeoutMs = this.timeoutSeconds * 1000;
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(async () => {
|
||||||
|
// Use smartshell's terminate() to kill entire process tree
|
||||||
|
await execResultStreaming.terminate();
|
||||||
|
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
tapParser.handleTapProcess(execResultStreaming.childProcess),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
// Clear timeout if test completed successfully
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
// Handle timeout error
|
||||||
|
tapParser.handleTimeout(this.timeoutSeconds);
|
||||||
|
// Ensure entire process tree is killed if still running
|
||||||
|
try {
|
||||||
|
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
|
||||||
|
} catch (killError) {
|
||||||
|
// Process tree might already be dead
|
||||||
|
}
|
||||||
|
await tapParser.evaluateFinalResult();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear warning timer if it was set
|
||||||
|
if (warningTimer) {
|
||||||
|
clearTimeout(warningTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tapParser;
|
||||||
|
}
|
||||||
|
}
|
211
ts/tstest.classes.runtime.parser.ts
Normal file
211
ts/tstest.classes.runtime.parser.ts
Normal 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('.');
|
||||||
|
}
|
@@ -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'):
|
// Check for nonci modifier in CI environment
|
||||||
const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
|
if (process.env.CI && parsed.modifiers.includes('nonci')) {
|
||||||
tapCombinator.addTapParser(tapParserBrowser);
|
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
|
||||||
break;
|
return;
|
||||||
case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
|
}
|
||||||
this.logger.sectionStart('Part 1: Chrome');
|
|
||||||
const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
|
// Show deprecation warning for legacy naming
|
||||||
tapCombinator.addTapParser(tapParserBothBrowser);
|
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();
|
||||||
|
}
|
||||||
this.logger.sectionStart('Part 2: Node');
|
|
||||||
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
|
|
||||||
tapCombinator.addTapParser(tapParserBothNode);
|
|
||||||
this.logger.sectionEnd();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
|
|
||||||
tapCombinator.addTapParser(tapParserNode);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
@@ -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,
|
||||||
|
Reference in New Issue
Block a user