ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
364 lines • 16 kB
JavaScript
;
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