UNPKG

ctrlshiftleft

Version:

AI-powered toolkit for embedding QA and security testing into development workflows

364 lines 16 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TestRunner = void 0; const playwright_1 = require("playwright"); const child_process_1 = require("child_process"); const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const glob_1 = require("glob"); const chalk_1 = __importDefault(require("chalk")); const perf_hooks_1 = require("perf_hooks"); const events_1 = require("events"); class TestRunner extends events_1.EventEmitter { constructor(options) { super(); this.options = { browser: options.browser || 'chromium', headless: options.headless !== false, timeout: options.timeout || 30, reporter: options.reporter || 'list', workers: options.workers || 1, filter: options.filter || '', retries: options.retries || 0, verbose: options.verbose || false, updateSnapshots: options.updateSnapshots || false }; } /** * Run tests from a file or directory * @param testPath Path to test file or directory * @returns Test results */ async runTests(testPath) { const startTime = perf_hooks_1.performance.now(); const testFiles = await this.getTestFiles(testPath); const results = { total: 0, passed: 0, failed: 0, skipped: 0, duration: 0, fileResults: [], errors: [] }; this.emit('run:start', { testFiles }); // If parallel execution is enabled if (this.options.workers && this.options.workers > 1) { // Run tests in parallel const chunks = this.chunkArray(testFiles, this.options.workers); const chunkResults = await Promise.all(chunks.map(chunk => this.runTestsChunk(chunk))); // Merge results for (const chunkResult of chunkResults) { results.total += chunkResult.total; results.passed += chunkResult.passed; results.failed += chunkResult.failed; results.skipped += chunkResult.skipped; results.fileResults.push(...chunkResult.fileResults); results.errors.push(...chunkResult.errors); } } else { // Run tests sequentially const fileResults = await Promise.all(testFiles.map(file => this.runTestFile(file))); for (const fileResult of fileResults) { results.total += fileResult.passed + fileResult.failed + fileResult.skipped; results.passed += fileResult.passed; results.failed += fileResult.failed; results.skipped += fileResult.skipped; results.fileResults.push(fileResult); results.errors.push(...fileResult.errors); } } results.duration = perf_hooks_1.performance.now() - startTime; this.emit('run:end', results); return results; } /** * Run a chunk of test files in parallel */ async runTestsChunk(testFiles) { const results = { total: 0, passed: 0, failed: 0, skipped: 0, duration: 0, fileResults: [], errors: [] }; // Initialize browser const browser = await this.launchBrowser(); try { for (const testFile of testFiles) { try { const fileResult = await this.runTestFile(testFile, browser); results.total += fileResult.passed + fileResult.failed + fileResult.skipped; results.passed += fileResult.passed; results.failed += fileResult.failed; results.skipped += fileResult.skipped; results.fileResults.push(fileResult); results.errors.push(...fileResult.errors); this.emit('file:end', fileResult); } catch (error) { const testError = { message: error.message, stack: error.stack, testPath: testFile }; results.failed += 1; results.total += 1; results.errors.push(testError); results.fileResults.push({ path: testFile, passed: 0, failed: 1, skipped: 0, duration: 0, errors: [testError], tests: [{ name: path_1.default.basename(testFile), status: 'failed', duration: 0, error: testError }] }); this.emit('error', testError); } } } finally { await browser.close(); } return results; } /** * Run tests from a single file */ async runTestFile(testFile, browser) { const startTime = perf_hooks_1.performance.now(); const relativePath = path_1.default.relative(process.cwd(), testFile); this.emit('file:start', { path: testFile }); const fileResult = { path: testFile, passed: 0, failed: 0, skipped: 0, duration: 0, errors: [], tests: [] }; // Create a browser if not provided let localBrowser = null; const actualBrowser = browser || (localBrowser = await this.launchBrowser()); try { // Create a new browser context for the test file const context = await actualBrowser.newContext(); const page = await context.newPage(); try { // Run in a try-catch to handle module loading errors try { // Import test file dynamically const testModule = require(testFile); const testSuite = testModule.default || testModule; if (typeof testSuite.runTests === 'function') { // Run the test suite this.emit('suite:start', { path: testFile }); // Create a test context with hooks for individual test reporting const testResults = []; const testContext = { beforeEach: (fn) => fn(), afterEach: (fn) => fn(), onTestStart: (name) => { this.emit('test:start', { path: testFile, name }); }, onTestComplete: (result) => { testResults.push(result); if (result.status === 'passed') { fileResult.passed++; } else if (result.status === 'failed') { fileResult.failed++; if (result.error) { fileResult.errors.push(result.error); } } else if (result.status === 'skipped') { fileResult.skipped++; } this.emit('test:end', { path: testFile, ...result }); } }; // Run the test suite with our context const suiteResults = await testSuite.runTests(page, { timeout: this.options.timeout * 1000, context: testContext, filter: this.options.filter, updateSnapshots: this.options.updateSnapshots }); // If the test suite used our hooks, we already have detailed results if (testResults.length === 0) { // Otherwise, use the legacy format fileResult.passed += suiteResults.passed; fileResult.failed += suiteResults.failed; fileResult.skipped += suiteResults.skipped; } else { fileResult.tests = testResults; } this.emit('suite:end', { path: testFile, passed: fileResult.passed, failed: fileResult.failed, skipped: fileResult.skipped }); } else if (Object.keys(testModule).some(key => key.startsWith('test'))) { // This appears to be a standard Playwright test file // We need to execute it through Playwright's test runner this.emit('suite:start', { path: testFile }); // Try to run with Playwright's test runner directly let projectConfig = { testDir: path_1.default.dirname(testFile), reporter: [['list']], timeout: this.options.timeout * 1000, retries: this.options.retries || 0 }; // Collect results via custom reporter const testResults = []; try { // Use the Playwright test runner via CLI const command = `npx playwright test ${path_1.default.basename(testFile)} --reporter=list ${this.options.headless ? '--headed' : ''} ${this.options.workers && this.options.workers > 1 ? `--workers=${this.options.workers}` : ''}`; if (this.options.verbose) { this.emit('log', { message: `Running command: ${command}`, level: 'debug' }); } try { (0, child_process_1.execSync)(command, { cwd: path_1.default.dirname(testFile), stdio: this.options.verbose ? 'inherit' : 'pipe', encoding: 'utf-8', timeout: this.options.timeout * 2000 // Double the timeout for the process }); // If we get here, the command succeeded fileResult.passed = 1; } catch (execError) { // The command failed with non-zero exit code fileResult.failed = 1; fileResult.errors.push({ message: `Playwright test run failed: ${execError.message}`, stack: execError.stack, testPath: testFile }); } } catch (error) { // If the programmatic test runner fails, fall back to manual execution // This is a fallback to ensure our runner still works fileResult.failed = 1; fileResult.errors.push({ message: `Failed to run Playwright test: ${error.message}`, stack: error.stack, testPath: testFile }); } } else { const error = `Warning: Test file does not export a runTests function: ${testFile}`; this.emit('warning', { path: testFile, message: error }); if (this.options.verbose) { console.warn(chalk_1.default.yellow(error)); } } } catch (error) { const testError = { message: `Error importing test file: ${error.message}`, stack: error.stack, testPath: testFile }; fileResult.failed += 1; fileResult.errors.push(testError); this.emit('error', testError); if (this.options.verbose) { console.error(chalk_1.default.red(`Error importing test file ${relativePath}:`), error); } } } finally { await context.close(); } } catch (error) { const testError = { message: `Browser context error: ${error.message}`, stack: error.stack, testPath: testFile }; fileResult.failed += 1; fileResult.errors.push(testError); this.emit('error', testError); if (this.options.verbose) { console.error(chalk_1.default.red(`Browser context error in ${relativePath}:`), error); } } finally { // Close the local browser if we created it if (localBrowser) { await localBrowser.close(); } } fileResult.duration = perf_hooks_1.performance.now() - startTime; return fileResult; } /** * Split an array into chunks for parallel processing */ chunkArray(array, chunkSize) { if (chunkSize <= 0) { return [array]; } const chunks = []; const totalChunks = Math.min(chunkSize, array.length); const itemsPerChunk = Math.ceil(array.length / totalChunks); for (let i = 0; i < totalChunks; i++) { const startIndex = i * itemsPerChunk; const endIndex = Math.min(startIndex + itemsPerChunk, array.length); chunks.push(array.slice(startIndex, endIndex)); } return chunks; } /** * Launch browser based on options */ async launchBrowser() { switch (this.options.browser) { case 'firefox': return playwright_1.firefox.launch({ headless: this.options.headless }); case 'webkit': return playwright_1.webkit.launch({ headless: this.options.headless }); case 'chromium': default: return playwright_1.chromium.launch({ headless: this.options.headless }); } } /** * Get test files from a path (file or directory) */ async getTestFiles(testPath) { const stats = await promises_1.default.stat(testPath); if (stats.isFile()) { return [testPath]; } if (stats.isDirectory()) { // Find all test files return (0, glob_1.glob)(`${testPath}/**/*.test.{js,ts}`); } return []; } } exports.TestRunner = TestRunner; //# sourceMappingURL=testRunner.js.map