@git.zone/tstest 2.3.7 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import * as plugins from './tstest.plugins.js';
2
+ import { coloredString as cs } from '@push.rocks/consolecolor';
3
+ import {
4
+ RuntimeAdapter,
5
+ type RuntimeOptions,
6
+ type RuntimeCommand,
7
+ type RuntimeAvailability,
8
+ } from './tstest.classes.runtime.adapter.js';
9
+ import { TapParser } from './tstest.classes.tap.parser.js';
10
+ import { TsTestLogger } from './tstest.logging.js';
11
+ import type { Runtime } from './tstest.classes.runtime.parser.js';
12
+
13
+ /**
14
+ * Node.js runtime adapter
15
+ * Executes tests using tsrun (TypeScript runner for Node.js)
16
+ */
17
+ export class NodeRuntimeAdapter extends RuntimeAdapter {
18
+ readonly id: Runtime = 'node';
19
+ readonly displayName: string = 'Node.js';
20
+
21
+ constructor(
22
+ private logger: TsTestLogger,
23
+ private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
24
+ private timeoutSeconds: number | null,
25
+ private filterTags: string[]
26
+ ) {
27
+ super();
28
+ }
29
+
30
+ /**
31
+ * Check if Node.js and tsrun are available
32
+ */
33
+ async checkAvailable(): Promise<RuntimeAvailability> {
34
+ try {
35
+ // Check Node.js version
36
+ const nodeVersion = process.version;
37
+
38
+ // Check if tsrun is available
39
+ const result = await this.smartshellInstance.exec('tsrun --version', {
40
+ cwd: process.cwd(),
41
+ onError: () => {
42
+ // Ignore error
43
+ }
44
+ });
45
+
46
+ if (result.exitCode !== 0) {
47
+ return {
48
+ available: false,
49
+ error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun',
50
+ };
51
+ }
52
+
53
+ return {
54
+ available: true,
55
+ version: nodeVersion,
56
+ };
57
+ } catch (error) {
58
+ return {
59
+ available: false,
60
+ error: error.message,
61
+ };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create command configuration for Node.js test execution
67
+ */
68
+ createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
69
+ const mergedOptions = this.mergeOptions(options);
70
+
71
+ // Build tsrun options
72
+ const args: string[] = [];
73
+
74
+ if (process.argv.includes('--web')) {
75
+ args.push('--web');
76
+ }
77
+
78
+ // Add any extra args
79
+ if (mergedOptions.extraArgs) {
80
+ args.push(...mergedOptions.extraArgs);
81
+ }
82
+
83
+ // Set environment variables
84
+ const env = { ...mergedOptions.env };
85
+
86
+ if (this.filterTags.length > 0) {
87
+ env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
88
+ }
89
+
90
+ return {
91
+ command: 'tsrun',
92
+ args: [testFile, ...args],
93
+ env,
94
+ cwd: mergedOptions.cwd,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Execute a test file in Node.js
100
+ */
101
+ async run(
102
+ testFile: string,
103
+ index: number,
104
+ total: number,
105
+ options?: RuntimeOptions
106
+ ): Promise<TapParser> {
107
+ this.logger.testFileStart(testFile, this.displayName, index, total);
108
+ const tapParser = new TapParser(testFile + ':node', this.logger);
109
+
110
+ const mergedOptions = this.mergeOptions(options);
111
+
112
+ // Build tsrun command
113
+ let tsrunOptions = '';
114
+ if (process.argv.includes('--web')) {
115
+ tsrunOptions += ' --web';
116
+ }
117
+
118
+ // Set filter tags as environment variable
119
+ if (this.filterTags.length > 0) {
120
+ process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
121
+ }
122
+
123
+ // Check for 00init.ts file in test directory
124
+ const testDir = plugins.path.dirname(testFile);
125
+ const initFile = plugins.path.join(testDir, '00init.ts');
126
+ let runCommand = `tsrun ${testFile}${tsrunOptions}`;
127
+
128
+ const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
129
+
130
+ // If 00init.ts exists, run it first
131
+ let loaderPath: string | null = null;
132
+ if (initFileExists) {
133
+ // Create a temporary loader file that imports both 00init.ts and the test file
134
+ const absoluteInitFile = plugins.path.resolve(initFile);
135
+ const absoluteTestFile = plugins.path.resolve(testFile);
136
+ const loaderContent = `
137
+ import '${absoluteInitFile.replace(/\\/g, '/')}';
138
+ import '${absoluteTestFile.replace(/\\/g, '/')}';
139
+ `;
140
+ loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
141
+ await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
142
+ runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
143
+ }
144
+
145
+ const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
146
+
147
+ // If we created a loader file, clean it up after test execution
148
+ if (loaderPath) {
149
+ const cleanup = () => {
150
+ try {
151
+ if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
152
+ plugins.smartfile.fs.removeSync(loaderPath);
153
+ }
154
+ } catch (e) {
155
+ // Ignore cleanup errors
156
+ }
157
+ };
158
+
159
+ execResultStreaming.childProcess.on('exit', cleanup);
160
+ execResultStreaming.childProcess.on('error', cleanup);
161
+ }
162
+
163
+ // Start warning timer if no timeout was specified
164
+ let warningTimer: NodeJS.Timeout | null = null;
165
+ if (this.timeoutSeconds === null) {
166
+ warningTimer = setTimeout(() => {
167
+ console.error('');
168
+ console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
169
+ console.error(cs(` File: ${testFile}`, 'orange'));
170
+ console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
171
+ console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
172
+ console.error('');
173
+ }, 60000); // 1 minute
174
+ }
175
+
176
+ // Handle timeout if specified
177
+ if (this.timeoutSeconds !== null) {
178
+ const timeoutMs = this.timeoutSeconds * 1000;
179
+ let timeoutId: NodeJS.Timeout;
180
+
181
+ const timeoutPromise = new Promise<void>((_resolve, reject) => {
182
+ timeoutId = setTimeout(async () => {
183
+ // Use smartshell's terminate() to kill entire process tree
184
+ await execResultStreaming.terminate();
185
+ reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
186
+ }, timeoutMs);
187
+ });
188
+
189
+ try {
190
+ await Promise.race([
191
+ tapParser.handleTapProcess(execResultStreaming.childProcess),
192
+ timeoutPromise
193
+ ]);
194
+ // Clear timeout if test completed successfully
195
+ clearTimeout(timeoutId);
196
+ } catch (error) {
197
+ // Clear warning timer if it was set
198
+ if (warningTimer) {
199
+ clearTimeout(warningTimer);
200
+ }
201
+ // Handle timeout error
202
+ tapParser.handleTimeout(this.timeoutSeconds);
203
+ // Ensure entire process tree is killed if still running
204
+ try {
205
+ await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
206
+ } catch (killError) {
207
+ // Process tree might already be dead
208
+ }
209
+ await tapParser.evaluateFinalResult();
210
+ }
211
+ } else {
212
+ await tapParser.handleTapProcess(execResultStreaming.childProcess);
213
+ }
214
+
215
+ // Clear warning timer if it was set
216
+ if (warningTimer) {
217
+ clearTimeout(warningTimer);
218
+ }
219
+
220
+ return tapParser;
221
+ }
222
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Runtime parser for test file naming convention
3
+ * Supports: test.runtime1+runtime2.modifier.ts
4
+ * Examples:
5
+ * - test.node.ts
6
+ * - test.chromium.ts
7
+ * - test.node+chromium.ts
8
+ * - test.deno+bun.ts
9
+ * - test.chromium.nonci.ts
10
+ */
11
+
12
+ export type Runtime = 'node' | 'chromium' | 'deno' | 'bun';
13
+ export type Modifier = 'nonci';
14
+
15
+ export interface ParsedFilename {
16
+ baseName: string;
17
+ runtimes: Runtime[];
18
+ modifiers: Modifier[];
19
+ extension: string;
20
+ isLegacy: boolean;
21
+ original: string;
22
+ }
23
+
24
+ export interface ParserConfig {
25
+ strictUnknownRuntime?: boolean; // default: true
26
+ defaultRuntimes?: Runtime[]; // default: ['node']
27
+ }
28
+
29
+ const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
30
+ const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']);
31
+ const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']);
32
+
33
+ // Legacy mappings for backwards compatibility
34
+ const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = {
35
+ browser: ['chromium'],
36
+ both: ['node', 'chromium'],
37
+ };
38
+
39
+ /**
40
+ * Parse a test filename to extract runtimes, modifiers, and detect legacy patterns
41
+ * Algorithm: Right-to-left token analysis from the extension
42
+ */
43
+ export function parseTestFilename(
44
+ filePath: string,
45
+ config: ParserConfig = {}
46
+ ): ParsedFilename {
47
+ const strictUnknownRuntime = config.strictUnknownRuntime ?? true;
48
+ const defaultRuntimes = config.defaultRuntimes ?? ['node'];
49
+
50
+ // Extract just the filename from the path
51
+ const fileName = filePath.split('/').pop() || filePath;
52
+ const original = fileName;
53
+
54
+ // Step 1: Extract and validate extension
55
+ const lastDot = fileName.lastIndexOf('.');
56
+ if (lastDot === -1) {
57
+ throw new Error(`Invalid test file: no extension found in "${fileName}"`);
58
+ }
59
+
60
+ const extension = fileName.substring(lastDot + 1);
61
+ if (!VALID_EXTENSIONS.has(extension)) {
62
+ throw new Error(
63
+ `Invalid test file extension ".${extension}" in "${fileName}". ` +
64
+ `Valid extensions: ${Array.from(VALID_EXTENSIONS).join(', ')}`
65
+ );
66
+ }
67
+
68
+ // Step 2: Split remaining basename by dots
69
+ const withoutExtension = fileName.substring(0, lastDot);
70
+ const tokens = withoutExtension.split('.');
71
+
72
+ if (tokens.length === 0) {
73
+ throw new Error(`Invalid test file: empty basename in "${fileName}"`);
74
+ }
75
+
76
+ // Step 3: Parse from right to left
77
+ let isLegacy = false;
78
+ const modifiers: Modifier[] = [];
79
+ let runtimes: Runtime[] = [];
80
+ let runtimeTokenIndex = -1;
81
+
82
+ // Scan from right to left
83
+ for (let i = tokens.length - 1; i >= 0; i--) {
84
+ const token = tokens[i];
85
+
86
+ // Check if this is a known modifier
87
+ if (KNOWN_MODIFIERS.has(token)) {
88
+ modifiers.unshift(token as Modifier);
89
+ continue;
90
+ }
91
+
92
+ // Check if this is a legacy runtime token
93
+ if (LEGACY_RUNTIME_MAP[token]) {
94
+ isLegacy = true;
95
+ runtimes = LEGACY_RUNTIME_MAP[token];
96
+ runtimeTokenIndex = i;
97
+ break;
98
+ }
99
+
100
+ // Check if this is a runtime chain (may contain + separators)
101
+ if (token.includes('+')) {
102
+ const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean);
103
+ const validRuntimes: Runtime[] = [];
104
+ const invalidRuntimes: string[] = [];
105
+
106
+ for (const candidate of runtimeCandidates) {
107
+ if (KNOWN_RUNTIMES.has(candidate)) {
108
+ // Dedupe: only add if not already in list
109
+ if (!validRuntimes.includes(candidate as Runtime)) {
110
+ validRuntimes.push(candidate as Runtime);
111
+ }
112
+ } else {
113
+ invalidRuntimes.push(candidate);
114
+ }
115
+ }
116
+
117
+ if (invalidRuntimes.length > 0) {
118
+ if (strictUnknownRuntime) {
119
+ throw new Error(
120
+ `Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
121
+ `Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}`
122
+ );
123
+ } else {
124
+ console.warn(
125
+ `⚠️ Warning: Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
126
+ `Defaulting to: ${defaultRuntimes.join('+')}`
127
+ );
128
+ runtimes = [...defaultRuntimes];
129
+ runtimeTokenIndex = i;
130
+ break;
131
+ }
132
+ }
133
+
134
+ if (validRuntimes.length > 0) {
135
+ runtimes = validRuntimes;
136
+ runtimeTokenIndex = i;
137
+ break;
138
+ }
139
+ }
140
+
141
+ // Check if this is a single runtime token
142
+ if (KNOWN_RUNTIMES.has(token)) {
143
+ runtimes = [token as Runtime];
144
+ runtimeTokenIndex = i;
145
+ break;
146
+ }
147
+
148
+ // If we've scanned past modifiers and haven't found a runtime, stop looking
149
+ if (modifiers.length > 0) {
150
+ break;
151
+ }
152
+ }
153
+
154
+ // Step 4: Determine base name
155
+ // Everything before the runtime token (if found) is the base name
156
+ const baseNameTokens = runtimeTokenIndex >= 0 ? tokens.slice(0, runtimeTokenIndex) : tokens;
157
+ const baseName = baseNameTokens.join('.');
158
+
159
+ // Step 5: Apply defaults if no runtime was detected
160
+ if (runtimes.length === 0) {
161
+ runtimes = [...defaultRuntimes];
162
+ }
163
+
164
+ return {
165
+ baseName: baseName || 'test',
166
+ runtimes,
167
+ modifiers,
168
+ extension,
169
+ isLegacy,
170
+ original,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Check if a filename uses legacy naming convention
176
+ */
177
+ export function isLegacyFilename(fileName: string): boolean {
178
+ const tokens = fileName.split('.');
179
+ for (const token of tokens) {
180
+ if (LEGACY_RUNTIME_MAP[token]) {
181
+ return true;
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Get the suggested new filename for a legacy filename
189
+ */
190
+ export function getLegacyMigrationTarget(fileName: string): string | null {
191
+ const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
192
+
193
+ if (!parsed.isLegacy) {
194
+ return null;
195
+ }
196
+
197
+ // Reconstruct filename with new naming
198
+ const parts = [parsed.baseName];
199
+
200
+ if (parsed.runtimes.length > 0) {
201
+ parts.push(parsed.runtimes.join('+'));
202
+ }
203
+
204
+ if (parsed.modifiers.length > 0) {
205
+ parts.push(...parsed.modifiers);
206
+ }
207
+
208
+ parts.push(parsed.extension);
209
+
210
+ return parts.join('.');
211
+ }
@@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js';
10
10
  import { TsTestLogger } from './tstest.logging.js';
11
11
  import type { LogOptions } from './tstest.logging.js';
12
12
 
13
+ // Runtime adapters
14
+ import { parseTestFilename } from './tstest.classes.runtime.parser.js';
15
+ import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js';
16
+ import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js';
17
+ import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js';
18
+ import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
19
+ import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
20
+
13
21
  export class TsTest {
14
22
  public testDir: TestDirectory;
15
23
  public executionMode: TestExecutionMode;
@@ -28,6 +36,8 @@ export class TsTest {
28
36
 
29
37
  public tsbundleInstance = new plugins.tsbundle.TsBundle();
30
38
 
39
+ public runtimeRegistry = new RuntimeAdapterRegistry();
40
+
31
41
  constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
32
42
  this.executionMode = executionModeArg;
33
43
  this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
@@ -36,6 +46,20 @@ export class TsTest {
36
46
  this.startFromFile = startFromFile;
37
47
  this.stopAtFile = stopAtFile;
38
48
  this.timeoutSeconds = timeoutSeconds;
49
+
50
+ // Register runtime adapters
51
+ this.runtimeRegistry.register(
52
+ new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
53
+ );
54
+ this.runtimeRegistry.register(
55
+ new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds)
56
+ );
57
+ this.runtimeRegistry.register(
58
+ new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
59
+ );
60
+ this.runtimeRegistry.register(
61
+ new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
62
+ );
39
63
  }
40
64
 
41
65
  async run() {
@@ -175,29 +199,50 @@ export class TsTest {
175
199
  }
176
200
 
177
201
  private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
178
- switch (true) {
179
- case process.env.CI && fileNameArg.includes('.nonci.'):
180
- this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
181
- break;
182
- case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
183
- const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
184
- tapCombinator.addTapParser(tapParserBrowser);
185
- break;
186
- case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
187
- this.logger.sectionStart('Part 1: Chrome');
188
- const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
189
- tapCombinator.addTapParser(tapParserBothBrowser);
190
- this.logger.sectionEnd();
191
-
192
- this.logger.sectionStart('Part 2: Node');
193
- const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
194
- tapCombinator.addTapParser(tapParserBothNode);
202
+ // Parse the filename to determine runtimes and modifiers
203
+ const fileName = plugins.path.basename(fileNameArg);
204
+ const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
205
+
206
+ // Check for nonci modifier in CI environment
207
+ if (process.env.CI && parsed.modifiers.includes('nonci')) {
208
+ this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
209
+ return;
210
+ }
211
+
212
+ // Show deprecation warning for legacy naming
213
+ if (parsed.isLegacy) {
214
+ console.warn('');
215
+ console.warn(cs('⚠️ DEPRECATION WARNING', 'orange'));
216
+ console.warn(cs(` File: ${fileName}`, 'orange'));
217
+ console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange'));
218
+ console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green'));
219
+ console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan'));
220
+ console.warn('');
221
+ }
222
+
223
+ // Get adapters for the specified runtimes
224
+ const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes);
225
+
226
+ if (adapters.length === 0) {
227
+ this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`);
228
+ return;
229
+ }
230
+
231
+ // Execute tests for each runtime
232
+ if (adapters.length === 1) {
233
+ // Single runtime - no sections needed
234
+ const adapter = adapters[0];
235
+ const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
236
+ tapCombinator.addTapParser(tapParser);
237
+ } else {
238
+ // Multiple runtimes - use sections
239
+ for (let i = 0; i < adapters.length; i++) {
240
+ const adapter = adapters[i];
241
+ this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
242
+ const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
243
+ tapCombinator.addTapParser(tapParser);
195
244
  this.logger.sectionEnd();
196
- break;
197
- default:
198
- const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
199
- tapCombinator.addTapParser(tapParserNode);
200
- break;
245
+ }
201
246
  }
202
247
  }
203
248
 
@@ -319,20 +364,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
319
364
  private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
320
365
  const smartnetwork = new plugins.smartnetwork.SmartNetwork();
321
366
 
322
- // Find HTTP port in range 30000-40000
323
- const httpPort = await smartnetwork.findFreePort(30000, 40000);
367
+ // Find random free HTTP port in range 30000-40000 to minimize collision chance
368
+ const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
324
369
  if (!httpPort) {
325
370
  throw new Error('Could not find a free HTTP port in range 30000-40000');
326
371
  }
327
372
 
328
- // Find WebSocket port in range 30000-40000 (different from HTTP port)
329
- let wsPort = await smartnetwork.findFreePort(httpPort + 1, 40000);
373
+ // Find random free WebSocket port, excluding the HTTP port to ensure they're different
374
+ const wsPort = await smartnetwork.findFreePort(30000, 40000, {
375
+ randomize: true,
376
+ exclude: [httpPort]
377
+ });
330
378
  if (!wsPort) {
331
- // Try again from the beginning of the range if we couldn't find one after httpPort
332
- wsPort = await smartnetwork.findFreePort(30000, httpPort - 1);
333
- if (!wsPort) {
334
- throw new Error('Could not find a free WebSocket port in range 30000-40000');
335
- }
379
+ throw new Error('Could not find a free WebSocket port in range 30000-40000');
336
380
  }
337
381
 
338
382
  // Log selected ports for debugging