294 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			294 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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: '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;
 | |
|   }
 | |
| }
 |