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 { 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 { 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(` `); 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((_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; } }