@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,293 @@
1
+ import * as plugins from './tstest.plugins.js';
2
+ import * as paths from './tstest.paths.js';
3
+ import { coloredString as cs } from '@push.rocks/consolecolor';
4
+ import {
5
+ RuntimeAdapter,
6
+ type ChromiumOptions,
7
+ type RuntimeCommand,
8
+ type RuntimeAvailability,
9
+ } from './tstest.classes.runtime.adapter.js';
10
+ import { TapParser } from './tstest.classes.tap.parser.js';
11
+ import { TsTestLogger } from './tstest.logging.js';
12
+ import type { Runtime } from './tstest.classes.runtime.parser.js';
13
+
14
+ /**
15
+ * Chromium runtime adapter
16
+ * Executes tests in a headless Chromium browser
17
+ */
18
+ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
19
+ readonly id: Runtime = 'chromium';
20
+ readonly displayName: string = 'Chromium';
21
+
22
+ constructor(
23
+ private logger: TsTestLogger,
24
+ private tsbundleInstance: any, // TsBundle instance from @push.rocks/tsbundle
25
+ private smartbrowserInstance: any, // SmartBrowser instance from @push.rocks/smartbrowser
26
+ private timeoutSeconds: number | null
27
+ ) {
28
+ super();
29
+ }
30
+
31
+ /**
32
+ * Check if Chromium is available
33
+ */
34
+ async checkAvailable(): Promise<RuntimeAvailability> {
35
+ try {
36
+ // Check if smartbrowser is available and can start
37
+ // The browser binary is usually handled by @push.rocks/smartbrowser
38
+ return {
39
+ available: true,
40
+ version: 'Chromium (via smartbrowser)',
41
+ };
42
+ } catch (error) {
43
+ return {
44
+ available: false,
45
+ error: error.message || 'Chromium not available',
46
+ };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create command configuration for Chromium test execution
52
+ * Note: Chromium tests don't use a traditional command, but this satisfies the interface
53
+ */
54
+ createCommand(testFile: string, options?: ChromiumOptions): RuntimeCommand {
55
+ const mergedOptions = this.mergeOptions(options);
56
+
57
+ return {
58
+ command: 'chromium',
59
+ args: [],
60
+ env: mergedOptions.env,
61
+ cwd: mergedOptions.cwd,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Find free ports for HTTP server and WebSocket
67
+ */
68
+ private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
69
+ const smartnetwork = new plugins.smartnetwork.SmartNetwork();
70
+
71
+ // Find random free HTTP port in range 30000-40000 to minimize collision chance
72
+ const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
73
+ if (!httpPort) {
74
+ throw new Error('Could not find a free HTTP port in range 30000-40000');
75
+ }
76
+
77
+ // Find random free WebSocket port, excluding the HTTP port to ensure they're different
78
+ const wsPort = await smartnetwork.findFreePort(30000, 40000, {
79
+ randomize: true,
80
+ exclude: [httpPort]
81
+ });
82
+ if (!wsPort) {
83
+ throw new Error('Could not find a free WebSocket port in range 30000-40000');
84
+ }
85
+
86
+ // Log selected ports for debugging
87
+ if (!this.logger.options.quiet) {
88
+ console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
89
+ }
90
+ return { httpPort, wsPort };
91
+ }
92
+
93
+ /**
94
+ * Execute a test file in Chromium browser
95
+ */
96
+ async run(
97
+ testFile: string,
98
+ index: number,
99
+ total: number,
100
+ options?: ChromiumOptions
101
+ ): Promise<TapParser> {
102
+ this.logger.testFileStart(testFile, this.displayName, index, total);
103
+
104
+ // lets get all our paths sorted
105
+ const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
106
+ const bundleFileName = testFile.replace('/', '__') + '.js';
107
+ const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
108
+
109
+ // lets bundle the test
110
+ await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
111
+ await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
112
+ bundler: 'esbuild',
113
+ });
114
+
115
+ // Find free ports for HTTP and WebSocket
116
+ const { httpPort, wsPort } = await this.findFreePorts();
117
+
118
+ // lets create a server
119
+ const server = new plugins.typedserver.servertools.Server({
120
+ cors: true,
121
+ port: httpPort,
122
+ });
123
+ server.addRoute(
124
+ '/test',
125
+ new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
126
+ res.type('.html');
127
+ res.write(`
128
+ <html>
129
+ <head>
130
+ <script>
131
+ globalThis.testdom = true;
132
+ globalThis.wsPort = ${wsPort};
133
+ </script>
134
+ </head>
135
+ <body></body>
136
+ </html>
137
+ `);
138
+ res.end();
139
+ })
140
+ );
141
+ server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
142
+ await server.start();
143
+
144
+ // lets handle realtime comms
145
+ const tapParser = new TapParser(testFile + ':chrome', this.logger);
146
+ const wss = new plugins.ws.WebSocketServer({ port: wsPort });
147
+ wss.on('connection', (ws) => {
148
+ ws.on('message', (message) => {
149
+ const messageStr = message.toString();
150
+ if (messageStr.startsWith('console:')) {
151
+ const [, level, ...messageParts] = messageStr.split(':');
152
+ this.logger.browserConsole(messageParts.join(':'), level);
153
+ } else {
154
+ tapParser.handleTapLog(messageStr);
155
+ }
156
+ });
157
+ });
158
+
159
+ // lets do the browser bit with timeout handling
160
+ await this.smartbrowserInstance.start();
161
+
162
+ const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
163
+ `http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
164
+ async () => {
165
+ // lets enable real time comms
166
+ const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
167
+ await new Promise((resolve) => (ws.onopen = resolve));
168
+
169
+ // Ensure this function is declared with 'async'
170
+ const logStore = [];
171
+ const originalLog = console.log;
172
+ const originalError = console.error;
173
+
174
+ // Override console methods to capture the logs
175
+ console.log = (...args: any[]) => {
176
+ logStore.push(args.join(' '));
177
+ ws.send(args.join(' '));
178
+ originalLog(...args);
179
+ };
180
+ console.error = (...args: any[]) => {
181
+ logStore.push(args.join(' '));
182
+ ws.send(args.join(' '));
183
+ originalError(...args);
184
+ };
185
+
186
+ const bundleName = new URLSearchParams(window.location.search).get('bundleName');
187
+ originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`);
188
+
189
+ try {
190
+ // Dynamically import the test module
191
+ const testModule = await import(`/${bundleName}`);
192
+ if (testModule && testModule.default && testModule.default instanceof Promise) {
193
+ // Execute the exported test function
194
+ await testModule.default;
195
+ } else if (testModule && testModule.default && typeof testModule.default.then === 'function') {
196
+ console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
197
+ console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.');
198
+ console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
199
+ await testModule.default;
200
+ } else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') {
201
+ console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
202
+ console.log('Using globalThis.tapPromise');
203
+ console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
204
+ await testModule.default;
205
+ } else {
206
+ console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
207
+ console.error('Test module does not export a default promise.');
208
+ console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
209
+ console.log(`We got: ${JSON.stringify(testModule)}`);
210
+ }
211
+ } catch (err) {
212
+ console.error(err);
213
+ }
214
+
215
+ return logStore.join('\n');
216
+ }
217
+ );
218
+
219
+ // Start warning timer if no timeout was specified
220
+ let warningTimer: NodeJS.Timeout | null = null;
221
+ if (this.timeoutSeconds === null) {
222
+ warningTimer = setTimeout(() => {
223
+ console.error('');
224
+ console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
225
+ console.error(cs(` File: ${testFile}`, 'orange'));
226
+ console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
227
+ console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
228
+ console.error('');
229
+ }, 60000); // 1 minute
230
+ }
231
+
232
+ // Handle timeout if specified
233
+ if (this.timeoutSeconds !== null) {
234
+ const timeoutMs = this.timeoutSeconds * 1000;
235
+ let timeoutId: NodeJS.Timeout;
236
+
237
+ const timeoutPromise = new Promise<void>((_resolve, reject) => {
238
+ timeoutId = setTimeout(() => {
239
+ reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
240
+ }, timeoutMs);
241
+ });
242
+
243
+ try {
244
+ await Promise.race([
245
+ evaluatePromise,
246
+ timeoutPromise
247
+ ]);
248
+ // Clear timeout if test completed successfully
249
+ clearTimeout(timeoutId);
250
+ } catch (error) {
251
+ // Clear warning timer if it was set
252
+ if (warningTimer) {
253
+ clearTimeout(warningTimer);
254
+ }
255
+ // Handle timeout error
256
+ tapParser.handleTimeout(this.timeoutSeconds);
257
+ }
258
+ } else {
259
+ await evaluatePromise;
260
+ }
261
+
262
+ // Clear warning timer if it was set
263
+ if (warningTimer) {
264
+ clearTimeout(warningTimer);
265
+ }
266
+
267
+ // Always clean up resources, even on timeout
268
+ try {
269
+ await this.smartbrowserInstance.stop();
270
+ } catch (error) {
271
+ // Browser might already be stopped
272
+ }
273
+
274
+ try {
275
+ await server.stop();
276
+ } catch (error) {
277
+ // Server might already be stopped
278
+ }
279
+
280
+ try {
281
+ wss.close();
282
+ } catch (error) {
283
+ // WebSocket server might already be closed
284
+ }
285
+
286
+ console.log(
287
+ `${cs('=> ', 'blue')} Stopped ${cs(testFile, 'orange')} chromium instance and server.`
288
+ );
289
+ // Always evaluate final result (handleTimeout just sets up the test state)
290
+ await tapParser.evaluateFinalResult();
291
+ return tapParser;
292
+ }
293
+ }
@@ -0,0 +1,244 @@
1
+ import * as plugins from './tstest.plugins.js';
2
+ import { coloredString as cs } from '@push.rocks/consolecolor';
3
+ import {
4
+ RuntimeAdapter,
5
+ type DenoOptions,
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
+ * Deno runtime adapter
15
+ * Executes tests using the Deno runtime
16
+ */
17
+ export class DenoRuntimeAdapter extends RuntimeAdapter {
18
+ readonly id: Runtime = 'deno';
19
+ readonly displayName: string = 'Deno';
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
+ * Get default Deno options
32
+ */
33
+ protected getDefaultOptions(): DenoOptions {
34
+ return {
35
+ ...super.getDefaultOptions(),
36
+ permissions: ['--allow-read', '--allow-env'],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Check if Deno is available
42
+ */
43
+ async checkAvailable(): Promise<RuntimeAvailability> {
44
+ try {
45
+ const result = await this.smartshellInstance.exec('deno --version', {
46
+ cwd: process.cwd(),
47
+ onError: () => {
48
+ // Ignore error
49
+ }
50
+ });
51
+
52
+ if (result.exitCode !== 0) {
53
+ return {
54
+ available: false,
55
+ error: 'Deno not found. Install from: https://deno.land/',
56
+ };
57
+ }
58
+
59
+ // Parse Deno version from output (first line is "deno X.Y.Z")
60
+ const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
61
+ const version = versionMatch ? versionMatch[1] : 'unknown';
62
+
63
+ return {
64
+ available: true,
65
+ version: `Deno ${version}`,
66
+ };
67
+ } catch (error) {
68
+ return {
69
+ available: false,
70
+ error: error.message,
71
+ };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Create command configuration for Deno test execution
77
+ */
78
+ createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
79
+ const mergedOptions = this.mergeOptions(options) as DenoOptions;
80
+
81
+ const args: string[] = ['run'];
82
+
83
+ // Add permissions
84
+ const permissions = mergedOptions.permissions || ['--allow-read', '--allow-env'];
85
+ args.push(...permissions);
86
+
87
+ // Add config file if specified
88
+ if (mergedOptions.configPath) {
89
+ args.push('--config', mergedOptions.configPath);
90
+ }
91
+
92
+ // Add import map if specified
93
+ if (mergedOptions.importMap) {
94
+ args.push('--import-map', mergedOptions.importMap);
95
+ }
96
+
97
+ // Add extra args
98
+ if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
99
+ args.push(...mergedOptions.extraArgs);
100
+ }
101
+
102
+ // Add test file
103
+ args.push(testFile);
104
+
105
+ // Set environment variables
106
+ const env = { ...mergedOptions.env };
107
+
108
+ if (this.filterTags.length > 0) {
109
+ env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
110
+ }
111
+
112
+ return {
113
+ command: 'deno',
114
+ args,
115
+ env,
116
+ cwd: mergedOptions.cwd,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Execute a test file in Deno
122
+ */
123
+ async run(
124
+ testFile: string,
125
+ index: number,
126
+ total: number,
127
+ options?: DenoOptions
128
+ ): Promise<TapParser> {
129
+ this.logger.testFileStart(testFile, this.displayName, index, total);
130
+ const tapParser = new TapParser(testFile + ':deno', this.logger);
131
+
132
+ const mergedOptions = this.mergeOptions(options) as DenoOptions;
133
+
134
+ // Build Deno command
135
+ const command = this.createCommand(testFile, mergedOptions);
136
+ const fullCommand = `${command.command} ${command.args.join(' ')}`;
137
+
138
+ // Set filter tags as environment variable
139
+ if (this.filterTags.length > 0) {
140
+ process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
141
+ }
142
+
143
+ // Check for 00init.ts file in test directory
144
+ const testDir = plugins.path.dirname(testFile);
145
+ const initFile = plugins.path.join(testDir, '00init.ts');
146
+ const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
147
+
148
+ let runCommand = fullCommand;
149
+ let loaderPath: string | null = null;
150
+
151
+ // If 00init.ts exists, create a loader file
152
+ if (initFileExists) {
153
+ const absoluteInitFile = plugins.path.resolve(initFile);
154
+ const absoluteTestFile = plugins.path.resolve(testFile);
155
+ const loaderContent = `
156
+ import '${absoluteInitFile.replace(/\\/g, '/')}';
157
+ import '${absoluteTestFile.replace(/\\/g, '/')}';
158
+ `;
159
+ loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
160
+ await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
161
+
162
+ // Rebuild command with loader file
163
+ const loaderCommand = this.createCommand(loaderPath, mergedOptions);
164
+ runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
165
+ }
166
+
167
+ const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
168
+
169
+ // If we created a loader file, clean it up after test execution
170
+ if (loaderPath) {
171
+ const cleanup = () => {
172
+ try {
173
+ if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
174
+ plugins.smartfile.fs.removeSync(loaderPath);
175
+ }
176
+ } catch (e) {
177
+ // Ignore cleanup errors
178
+ }
179
+ };
180
+
181
+ execResultStreaming.childProcess.on('exit', cleanup);
182
+ execResultStreaming.childProcess.on('error', cleanup);
183
+ }
184
+
185
+ // Start warning timer if no timeout was specified
186
+ let warningTimer: NodeJS.Timeout | null = null;
187
+ if (this.timeoutSeconds === null) {
188
+ warningTimer = setTimeout(() => {
189
+ console.error('');
190
+ console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
191
+ console.error(cs(` File: ${testFile}`, 'orange'));
192
+ console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
193
+ console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
194
+ console.error('');
195
+ }, 60000); // 1 minute
196
+ }
197
+
198
+ // Handle timeout if specified
199
+ if (this.timeoutSeconds !== null) {
200
+ const timeoutMs = this.timeoutSeconds * 1000;
201
+ let timeoutId: NodeJS.Timeout;
202
+
203
+ const timeoutPromise = new Promise<void>((_resolve, reject) => {
204
+ timeoutId = setTimeout(async () => {
205
+ // Use smartshell's terminate() to kill entire process tree
206
+ await execResultStreaming.terminate();
207
+ reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
208
+ }, timeoutMs);
209
+ });
210
+
211
+ try {
212
+ await Promise.race([
213
+ tapParser.handleTapProcess(execResultStreaming.childProcess),
214
+ timeoutPromise
215
+ ]);
216
+ // Clear timeout if test completed successfully
217
+ clearTimeout(timeoutId);
218
+ } catch (error) {
219
+ // Clear warning timer if it was set
220
+ if (warningTimer) {
221
+ clearTimeout(warningTimer);
222
+ }
223
+ // Handle timeout error
224
+ tapParser.handleTimeout(this.timeoutSeconds);
225
+ // Ensure entire process tree is killed if still running
226
+ try {
227
+ await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
228
+ } catch (killError) {
229
+ // Process tree might already be dead
230
+ }
231
+ await tapParser.evaluateFinalResult();
232
+ }
233
+ } else {
234
+ await tapParser.handleTapProcess(execResultStreaming.childProcess);
235
+ }
236
+
237
+ // Clear warning timer if it was set
238
+ if (warningTimer) {
239
+ clearTimeout(warningTimer);
240
+ }
241
+
242
+ return tapParser;
243
+ }
244
+ }