Compare commits

..

6 Commits

11 changed files with 7802 additions and 213 deletions

View File

@@ -1,5 +1,35 @@
# Changelog # Changelog
## 2025-10-17 - 2.5.2 - fix(runtime.node)
Improve Node runtime adapter to use tsrun.spawnPath, strengthen tsrun detection, and improve process lifecycle and loader handling; update tsrun dependency.
- Use tsrun.spawnPath to spawn Node test processes and pass structured spawn options (cwd, env, args, stdio).
- Detect tsrun availability via plugins.tsrun and require spawnPath; provide a clearer error message when tsrun is missing or outdated.
- Pass --web via spawn args and set TSTEST_FILTER_TAGS on the spawned process env instead of mutating the parent process.env.
- When a 00init.ts exists, create a temporary loader that imports both 00init.ts and the test file, run the loader via tsrun.spawnPath, and clean up the loader after execution.
- Use tsrunProcess.terminate()/kill for timeouts to ensure proper process termination and improve cleanup handling.
- Export tsrun from ts/tstest.plugins.ts so runtime code can access tsrun APIs via the plugins object.
- Bump dependency @git.zone/tsrun from ^1.3.4 to ^1.6.2 in package.json.
## 2025-10-16 - 2.5.1 - fix(deps)
Bump dependencies and add local tooling settings
- Bumped @api.global/typedserver from ^3.0.78 to ^3.0.79
- Bumped @git.zone/tsrun from ^1.3.3 to ^1.3.4
- Bumped @push.rocks/smartjson from ^5.0.20 to ^5.2.0
- Bumped @push.rocks/smartlog from ^3.1.9 to ^3.1.10
- Add local settings configuration file for developer tooling
## 2025-10-12 - 2.5.0 - feat(tstest.classes.runtime.parser)
Add support for "all" runtime token and update docs/tests; regenerate lockfile and add local settings
- Add support for the `all` runtime token (expands to node, chromium, deno, bun) in tstest filename parser (tstest.classes.runtime.parser)
- Handle `all` with modifiers (e.g. `*.all.nonci.ts`) and mixed tokens (e.g. `node+all`) so it expands to the full runtime set
- Add unit tests covering `all` cases in test/test.runtime.parser.node.ts
- Update README (examples and tables) to document `.all.ts` and `.all.nonci.ts` usage and include a universal example
- Update ts files' parser comments and constants to include ALL_RUNTIMES
- Add deno.lock (dependency lockfile) and a local .claude/settings.local.json for project permissions / local settings
## 2025-10-11 - 2.4.3 - fix(docs) ## 2025-10-11 - 2.4.3 - fix(docs)
Update documentation: expand README with multi-runtime architecture, add module READMEs, and add local dev settings Update documentation: expand README with multi-runtime architecture, add module READMEs, and add local dev settings

0
cli.js Normal file → Executable file
View File

7244
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tstest", "name": "@git.zone/tstest",
"version": "2.4.3", "version": "2.5.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": {
@@ -28,9 +28,9 @@
"@types/node": "^22.15.21" "@types/node": "^22.15.21"
}, },
"dependencies": { "dependencies": {
"@api.global/typedserver": "^3.0.78", "@api.global/typedserver": "^3.0.79",
"@git.zone/tsbundle": "^2.5.1", "@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^1.6.2",
"@push.rocks/consolecolor": "^2.0.3", "@push.rocks/consolecolor": "^2.0.3",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartbrowser": "^2.0.8", "@push.rocks/smartbrowser": "^2.0.8",
@@ -40,8 +40,8 @@
"@push.rocks/smartenv": "^5.0.13", "@push.rocks/smartenv": "^5.0.13",
"@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.2.0",
"@push.rocks/smartlog": "^3.1.9", "@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartmongo": "^2.0.12", "@push.rocks/smartmongo": "^2.0.12",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",

588
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -61,15 +61,29 @@ Name your test files with runtime specifiers to control where they run:
| `*.chromium.ts` | Chromium browser | `test.dom.chromium.ts` | | `*.chromium.ts` | Chromium browser | `test.dom.chromium.ts` |
| `*.deno.ts` | Deno runtime | `test.http.deno.ts` | | `*.deno.ts` | Deno runtime | `test.http.deno.ts` |
| `*.bun.ts` | Bun runtime | `test.fast.bun.ts` | | `*.bun.ts` | Bun runtime | `test.fast.bun.ts` |
| `*.all.ts` | All runtimes (Node, Chromium, Deno, Bun) | `test.universal.all.ts` |
| `*.node+chromium.ts` | Both Node.js and Chromium | `test.isomorphic.node+chromium.ts` | | `*.node+chromium.ts` | Both Node.js and Chromium | `test.isomorphic.node+chromium.ts` |
| `*.node+deno.ts` | Both Node.js and Deno | `test.cross.node+deno.ts` | | `*.node+deno.ts` | Both Node.js and Deno | `test.cross.node+deno.ts` |
| `*.deno+bun.ts` | Both Deno and Bun | `test.modern.deno+bun.ts` | | `*.deno+bun.ts` | Both Deno and Bun | `test.modern.deno+bun.ts` |
| `*.chromium.nonci.ts` | Chromium, skip in CI | `test.visual.chromium.nonci.ts` | | `*.chromium.nonci.ts` | Chromium, skip in CI | `test.visual.chromium.nonci.ts` |
| `*.all.nonci.ts` | All runtimes, skip in CI | `test.comprehensive.all.nonci.ts` |
**Multi-Runtime Examples:** **Multi-Runtime Examples:**
```typescript ```typescript
// test.api.node+deno+bun.ts - runs in Node.js, Deno, and Bun // test.api.all.ts - runs in all runtimes (Node, Chromium, Deno, Bun)
import { expect, tap } from '@git.zone/tstest/tapbundle';
tap.test('universal HTTP test', async () => {
const response = await fetch('https://api.example.com/test');
expect(response.status).toEqual(200);
});
export default tap.start();
```
```typescript
// test.api.node+deno+bun.ts - runs in specific runtimes
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
tap.test('cross-runtime HTTP test', async () => { tap.test('cross-runtime HTTP test', async () => {
@@ -915,6 +929,7 @@ tstest test/api/endpoints.test.ts --verbose --timeout 60
### Version 2.4.0 ### Version 2.4.0
- 🚀 **Multi-Runtime Architecture** - Support for Deno, Bun, Node.js, and Chromium - 🚀 **Multi-Runtime Architecture** - Support for Deno, Bun, Node.js, and Chromium
- 🔀 **New Naming Convention** - Flexible `.runtime1+runtime2.ts` pattern - 🔀 **New Naming Convention** - Flexible `.runtime1+runtime2.ts` pattern
- 🌐 **Universal Testing** - `.all.ts` pattern runs tests on all supported runtimes
- 🔄 **Migration Tool** - Easy migration from legacy naming (`.browser.ts`, `.both.ts`) - 🔄 **Migration Tool** - Easy migration from legacy naming (`.browser.ts`, `.both.ts`)
- 🦕 **Deno Support** - Full Deno runtime with Node.js compatibility - 🦕 **Deno Support** - Full Deno runtime with Node.js compatibility
- 🐰 **Bun Support** - Ultra-fast Bun runtime integration - 🐰 **Bun Support** - Ultra-fast Bun runtime integration

View File

@@ -164,4 +164,40 @@ tap.test('parseTestFilename - handles full paths', async () => {
expect(parsed.original).toEqual('test.node+chromium.ts'); expect(parsed.original).toEqual('test.node+chromium.ts');
}); });
tap.test('parseTestFilename - all keyword expands to all runtimes', async () => {
const parsed = parseTestFilename('test.all.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - all keyword with nonci modifier', async () => {
const parsed = parseTestFilename('test.all.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - all keyword with complex basename', async () => {
const parsed = parseTestFilename('test.some.feature.all.ts');
expect(parsed.baseName).toEqual('test.some.feature');
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - all keyword in chain expands to all runtimes', async () => {
const parsed = parseTestFilename('test.node+all.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium', 'deno', 'bun']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
export default tap.start(); export default tap.start();

View File

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

View File

@@ -35,18 +35,11 @@ export class NodeRuntimeAdapter extends RuntimeAdapter {
// Check Node.js version // Check Node.js version
const nodeVersion = process.version; const nodeVersion = process.version;
// Check if tsrun is available // Check if tsrun module is available (imported as dependency)
const result = await this.smartshellInstance.exec('tsrun --version', { if (!plugins.tsrun || !plugins.tsrun.spawnPath) {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return { return {
available: false, available: false,
error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun', error: 'tsrun module not found or outdated (requires version 1.6.0+)',
}; };
} }
@@ -96,7 +89,7 @@ export class NodeRuntimeAdapter extends RuntimeAdapter {
} }
/** /**
* Execute a test file in Node.js * Execute a test file in Node.js using tsrun's spawnPath API
*/ */
async run( async run(
testFile: string, testFile: string,
@@ -109,28 +102,35 @@ export class NodeRuntimeAdapter extends RuntimeAdapter {
const mergedOptions = this.mergeOptions(options); const mergedOptions = this.mergeOptions(options);
// Build tsrun command // Build spawn options
let tsrunOptions = ''; const spawnOptions: any = {
cwd: mergedOptions.cwd || process.cwd(),
env: { ...mergedOptions.env },
args: [] as string[],
stdio: 'pipe' as const,
};
// Add --web flag if needed
if (process.argv.includes('--web')) { if (process.argv.includes('--web')) {
tsrunOptions += ' --web'; spawnOptions.args.push('--web');
} }
// Set filter tags as environment variable // Set filter tags as environment variable
if (this.filterTags.length > 0) { if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); spawnOptions.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
} }
// Check for 00init.ts file in test directory // Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile); const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts'); const initFile = plugins.path.join(testDir, '00init.ts');
let runCommand = `tsrun ${testFile}${tsrunOptions}`;
const initFileExists = await plugins.smartfile.fs.fileExists(initFile); const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
// If 00init.ts exists, run it first // Determine which file to run
let fileToRun = testFile;
let loaderPath: string | null = null; let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) { if (initFileExists) {
// Create a temporary loader file that imports both 00init.ts and the test file
const absoluteInitFile = plugins.path.resolve(initFile); const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile); const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = ` const loaderContent = `
@@ -139,10 +139,12 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
`; `;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`); loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath); await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
runCommand = `tsrun ${loaderPath}${tsrunOptions}`; fileToRun = loaderPath;
} }
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); // Spawn the test process using tsrun's spawnPath API
// Pass undefined for fromFileUrl since fileToRun is already an absolute path
const tsrunProcess = plugins.tsrun.spawnPath(fileToRun, undefined, spawnOptions);
// If we created a loader file, clean it up after test execution // If we created a loader file, clean it up after test execution
if (loaderPath) { if (loaderPath) {
@@ -156,8 +158,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
} }
}; };
execResultStreaming.childProcess.on('exit', cleanup); tsrunProcess.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup); tsrunProcess.childProcess.on('error', cleanup);
} }
// Start warning timer if no timeout was specified // Start warning timer if no timeout was specified
@@ -180,15 +182,15 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
const timeoutPromise = new Promise<void>((_resolve, reject) => { const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => { timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree // Use tsrun's terminate() to gracefully kill the process
await execResultStreaming.terminate(); await tsrunProcess.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs); }, timeoutMs);
}); });
try { try {
await Promise.race([ await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess), tapParser.handleTapProcess(tsrunProcess.childProcess),
timeoutPromise timeoutPromise
]); ]);
// Clear timeout if test completed successfully // Clear timeout if test completed successfully
@@ -200,16 +202,16 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
} }
// Handle timeout error // Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds); tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running // Ensure process is killed if still running
try { try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL tsrunProcess.kill('SIGKILL');
} catch (killError) { } catch (killError) {
// Process tree might already be dead // Process might already be dead
} }
await tapParser.evaluateFinalResult(); await tapParser.evaluateFinalResult();
} }
} else { } else {
await tapParser.handleTapProcess(execResultStreaming.childProcess); await tapParser.handleTapProcess(tsrunProcess.childProcess);
} }
// Clear warning timer if it was set // Clear warning timer if it was set

View File

@@ -6,6 +6,7 @@
* - test.chromium.ts * - test.chromium.ts
* - test.node+chromium.ts * - test.node+chromium.ts
* - test.deno+bun.ts * - test.deno+bun.ts
* - test.all.ts (runs on all runtimes)
* - test.chromium.nonci.ts * - test.chromium.nonci.ts
*/ */
@@ -29,6 +30,7 @@ export interface ParserConfig {
const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']); const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']); const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']);
const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']); const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']);
const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun'];
// Legacy mappings for backwards compatibility // Legacy mappings for backwards compatibility
const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = { const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = {
@@ -102,9 +104,12 @@ export function parseTestFilename(
const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean); const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean);
const validRuntimes: Runtime[] = []; const validRuntimes: Runtime[] = [];
const invalidRuntimes: string[] = []; const invalidRuntimes: string[] = [];
let hasAllKeyword = false;
for (const candidate of runtimeCandidates) { for (const candidate of runtimeCandidates) {
if (KNOWN_RUNTIMES.has(candidate)) { if (candidate === 'all') {
hasAllKeyword = true;
} else if (KNOWN_RUNTIMES.has(candidate)) {
// Dedupe: only add if not already in list // Dedupe: only add if not already in list
if (!validRuntimes.includes(candidate as Runtime)) { if (!validRuntimes.includes(candidate as Runtime)) {
validRuntimes.push(candidate as Runtime); validRuntimes.push(candidate as Runtime);
@@ -114,11 +119,18 @@ export function parseTestFilename(
} }
} }
// If 'all' keyword is present, expand to all runtimes
if (hasAllKeyword) {
runtimes = [...ALL_RUNTIMES];
runtimeTokenIndex = i;
break;
}
if (invalidRuntimes.length > 0) { if (invalidRuntimes.length > 0) {
if (strictUnknownRuntime) { if (strictUnknownRuntime) {
throw new Error( throw new Error(
`Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` + `Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
`Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}` `Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}, all`
); );
} else { } else {
console.warn( console.warn(
@@ -138,6 +150,13 @@ export function parseTestFilename(
} }
} }
// Check if this is the 'all' keyword (expands to all runtimes)
if (token === 'all') {
runtimes = [...ALL_RUNTIMES];
runtimeTokenIndex = i;
break;
}
// Check if this is a single runtime token // Check if this is a single runtime token
if (KNOWN_RUNTIMES.has(token)) { if (KNOWN_RUNTIMES.has(token)) {
runtimes = [token as Runtime]; runtimes = [token as Runtime];

View File

@@ -37,8 +37,9 @@ export {
// @git.zone scope // @git.zone scope
import * as tsbundle from '@git.zone/tsbundle'; import * as tsbundle from '@git.zone/tsbundle';
import * as tsrun from '@git.zone/tsrun';
export { tsbundle }; export { tsbundle, tsrun };
// sindresorhus // sindresorhus
import figures from 'figures'; import figures from 'figures';