@debugg-ai/cli
Version:
CLI tool for running DebuggAI tests in CI/CD environments
959 lines (949 loc) • 46.3 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TestManager = void 0;
const client_1 = require("../backend/cli/client");
const git_analyzer_1 = require("./git-analyzer");
const tunnel_manager_1 = require("./tunnel-manager");
const fs = __importStar(require("fs-extra"));
const path = __importStar(require("path"));
const chalk_1 = __importDefault(require("chalk"));
const system_logger_1 = require("../util/system-logger");
const telemetry_1 = require("../services/telemetry");
/**
* Manages the complete test lifecycle from commit analysis to result reporting
*/
class TestManager {
constructor(options) {
this.options = {
testOutputDir: 'tests/debugg-ai',
serverTimeout: 30000, // 30 seconds
maxTestWaitTime: 600000, // 10 minutes
downloadArtifacts: false, // Default to NOT downloading artifacts for CI/CD environments
...options
};
this.client = new client_1.CLIBackendClient({
apiKey: options.apiKey,
baseUrl: options.baseUrl || 'https://api.debugg.ai',
repoPath: options.repoPath,
timeout: this.options.serverTimeout || 30000
});
this.gitAnalyzer = new git_analyzer_1.GitAnalyzer({
repoPath: options.repoPath
});
// Initialize tunnel manager if tunnel creation is requested
if (this.options.createTunnel) {
this.tunnelManager = new tunnel_manager_1.TunnelManager({
baseDomain: 'ngrok.debugg.ai'
});
}
}
/**
* Create TestManager with tunnel URL support
*/
static withTunnel(options, tunnelUrl, tunnelMetadata) {
return new TestManager({
...options,
tunnelUrl,
tunnelMetadata
});
}
/**
* Create TestManager that will create an ngrok tunnel after backend provides tunnelKey
* This is the correct flow: Backend creates commit suite -> provides tunnelKey -> create tunnel
*/
static withAutoTunnel(options, endpointUuid, tunnelPort = 3000) {
return new TestManager({
...options,
tunnelKey: endpointUuid,
createTunnel: true,
tunnelPort
});
}
/**
* Run tests for the current commit or working changes
*/
async runCommitTests() {
const testStartTime = Date.now();
system_logger_1.systemLogger.info('Starting test analysis and generation', { category: 'test' });
try {
// Step 1: Validate git repository
const isValidRepo = await this.gitAnalyzer.validateGitRepo();
if (!isValidRepo) {
throw new Error('Not a valid git repository');
}
// Step 2: Initialize the CLI client (includes connection test)
system_logger_1.systemLogger.info('Initializing backend client', { category: 'test' });
await this.client.initialize();
// Step 3: Test authentication
system_logger_1.systemLogger.info('Validating API key', { category: 'api' });
const authTest = await this.client.testAuthentication();
if (!authTest.success) {
throw new Error(`Authentication failed: ${authTest.error}`);
}
system_logger_1.systemLogger.api.auth(true, authTest.user?.email || authTest.user?.id);
// Step 3.4: Check if GitHub App PR testing is enabled
if (this.options.pr) {
system_logger_1.systemLogger.info(`GitHub App PR testing enabled - PR #${this.options.pr}`, { category: 'test' });
return await this.runGitHubAppPRTest();
}
// Step 3.5: Check if PR sequence testing is enabled
if (this.options.prSequence) {
system_logger_1.systemLogger.info('PR sequence testing enabled - analyzing commit sequence', { category: 'test' });
return await this.runPRCommitSequenceTests();
}
// Step 4: Validate tunnel URL if provided (simplified for now)
if (this.options.tunnelUrl) {
system_logger_1.systemLogger.info('Using tunnel URL', { category: 'tunnel' });
// Note: Tunnel validation can be added later if needed
system_logger_1.systemLogger.info(`Using tunnel URL: ${this.options.tunnelUrl}`);
}
// Step 5: Analyze git changes
system_logger_1.systemLogger.info('Analyzing git changes', { category: 'git' });
const changes = await this.analyzeChanges();
if (changes.changes.length === 0) {
system_logger_1.systemLogger.success('No changes detected - skipping test generation');
return {
success: true,
testFiles: []
};
}
system_logger_1.systemLogger.info(`Found ${changes.changes.length} changed files`, { category: 'git' });
// Track test execution start
const executionType = this.options.pr ? 'pr' :
this.options.prSequence ? 'pr-sequence' :
this.options.commit ? 'commit' : 'working';
telemetry_1.telemetry.trackTestStart(executionType, {
filesChanged: changes.changes.length,
branch: changes.branchInfo.branch,
hasCommit: !!this.options.commit,
hasCommitRange: !!this.options.commitRange,
hasSince: !!this.options.since,
hasLast: !!this.options.last
});
// Step 7: Submit test request
system_logger_1.systemLogger.info('Creating test suite', { category: 'test' });
// Create test description for the changes
const testDescription = await this.createTestDescription(changes);
// Get PR number if available
const prNumber = this.gitAnalyzer.getPRNumber();
const testRequest = {
repoName: this.gitAnalyzer.getRepoName(),
repoPath: this.options.repoPath,
branchName: changes.branchInfo.branch,
commitHash: changes.branchInfo.commitHash,
workingChanges: changes.changes,
testDescription,
...(prNumber && { prNumber })
};
// Add tunnel key (UUID) for custom endpoints (e.g., <uuid>.debugg.ai)
// This tells the backend which subdomain to expect the tunnel on
if (this.options.tunnelKey) {
testRequest.key = this.options.tunnelKey;
system_logger_1.systemLogger.debug('Sending tunnel key to backend', {
category: 'tunnel',
details: { key: this.options.tunnelKey.substring(0, 8) + '...' }
});
}
// if (this.options.tunnelUrl) {
// testRequest.publicUrl = this.options.tunnelUrl;
// testRequest.testEnvironment = {
// url: this.options.tunnelUrl,
// type: 'ngrok_tunnel' as const,
// metadata: this.options.tunnelMetadata
// };
// }
const response = await this.client.createCommitTestSuite(testRequest);
if (!response.success || !response.testSuiteUuid) {
throw new Error(`Failed to create test suite: ${response.error}`);
}
system_logger_1.systemLogger.info(`Test suite created: ${response.testSuiteUuid}`, { category: 'test' });
// Step 7.5: Create tunnel if requested and backend provided tunnelKey
let tunnelInfo;
system_logger_1.systemLogger.debug('Tunnel setup', {
category: 'tunnel',
details: {
createTunnel: this.options.createTunnel,
tunnelKey: this.options.tunnelKey,
backendTunnelKey: response.tunnelKey
}
});
if (response.tunnelKey && this.options.tunnelKey) {
system_logger_1.systemLogger.info('Setting up ngrok tunnel', { category: 'tunnel' });
system_logger_1.systemLogger.info('TUNNEL SETUP');
system_logger_1.systemLogger.info(`Endpoint UUID: ${this.options.tunnelKey}`);
system_logger_1.systemLogger.info(`Expected URL: https://${this.options.tunnelKey}.ngrok.debugg.ai`);
system_logger_1.systemLogger.info(`Local port: ${this.options.tunnelPort || 3000}`);
system_logger_1.systemLogger.info(`Backend provided tunnelKey: ${response.tunnelKey ? '✓ YES' : '✗ NO'}`);
if (!this.tunnelManager) {
throw new Error('Tunnel manager not initialized. This should not happen.');
}
const tunnelPort = this.options.tunnelPort || 3000;
try {
system_logger_1.systemLogger.info('Starting ngrok tunnel');
tunnelInfo = await this.tunnelManager.createTunnelWithBackendKey(tunnelPort, this.options.tunnelKey, // UUID for endpoint
response.tunnelKey // ngrok auth token from backend
);
// Store tunnel info for later use
this.tunnelInfo = tunnelInfo;
system_logger_1.systemLogger.tunnel.connected(tunnelInfo.url);
system_logger_1.systemLogger.success(`TUNNEL ACTIVE: ${tunnelInfo.url} -> localhost:${tunnelPort}`);
// Track successful tunnel creation
telemetry_1.telemetry.trackTunnelCreation(true, tunnelPort);
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
system_logger_1.systemLogger.error(`TUNNEL FAILED: ${errorMsg}`);
system_logger_1.systemLogger.warn('Tests will proceed without tunnel - ensure your server is accessible at the expected URL');
system_logger_1.systemLogger.warn(`Expected backend URL: https://${this.options.tunnelKey}.ngrok.debugg.ai`);
// Track tunnel creation failure
telemetry_1.telemetry.trackTunnelCreation(false, tunnelPort, errorMsg);
}
}
else {
// Log why tunnel wasn't created
if (this.options.createTunnel) {
system_logger_1.systemLogger.info('TUNNEL SETUP SKIPPED');
system_logger_1.systemLogger.debug(`createTunnel: ${this.options.createTunnel ? '✓' : '✗'}`);
system_logger_1.systemLogger.debug(`tunnelKey provided: ${this.options.tunnelKey ? '✓' : '✗'}`);
system_logger_1.systemLogger.debug(`backend tunnelKey: ${response.tunnelKey ? '✓' : '✗'}`);
}
}
// Step 8: Wait for tests to complete
system_logger_1.systemLogger.info('Waiting for tests to complete', { category: 'test' });
const completedSuite = await this.client.waitForCommitTestSuiteCompletion(response.testSuiteUuid, {
maxWaitTime: this.options.maxTestWaitTime || 600000,
pollInterval: 5000,
onProgress: (suite) => {
const testCount = suite.tests?.length || 0;
const completedTests = suite.tests?.filter((t) => t.curRun?.status === 'completed' || t.curRun?.status === 'failed').length || 0;
system_logger_1.systemLogger.info(`Running tests... (${completedTests}/${testCount} completed)`, { category: 'test' });
}
});
if (!completedSuite) {
throw new Error('Test suite timed out or failed to complete');
}
// Step 9: Download and save test artifacts (only if enabled)
let testFiles = [];
if (this.options.downloadArtifacts) {
system_logger_1.systemLogger.info('Downloading test artifacts', { category: 'test' });
testFiles = await this.saveTestArtifacts(completedSuite);
}
else {
system_logger_1.systemLogger.debug('Skipping artifact download - downloadArtifacts is disabled', { category: 'test' });
}
// Step 10: Report results
this.reportResults(completedSuite);
if (this.options.downloadArtifacts) {
system_logger_1.systemLogger.success(`Tests completed successfully! Generated ${testFiles.length} test files`);
telemetry_1.telemetry.trackArtifactDownload('test_files', true, testFiles.length);
}
else {
system_logger_1.systemLogger.success('Tests completed successfully! (artifacts not downloaded - use --download-artifacts to save test files)');
}
// Track test completion
const testsGenerated = completedSuite.tests?.length || 0;
const testExecutionType = this.options.pr ? 'pr' :
this.options.prSequence ? 'pr-sequence' :
this.options.commit ? 'commit' : 'working';
telemetry_1.telemetry.trackTestComplete({
suiteUuid: response.testSuiteUuid,
duration: Date.now() - testStartTime,
filesChanged: changes.changes.length,
testsGenerated,
success: true,
executionType: testExecutionType
});
const result = {
success: true,
suiteUuid: response.testSuiteUuid,
suite: completedSuite,
testFiles
};
// Add optional fields only if they have values
if (response.tunnelKey) {
result.tunnelKey = response.tunnelKey;
}
if (tunnelInfo) {
result.tunnelInfo = tunnelInfo;
}
return result;
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
system_logger_1.systemLogger.error(`Test run failed: ${errorMsg}`);
// Track test failure
const failureExecutionType = this.options.pr ? 'pr' :
this.options.prSequence ? 'pr-sequence' :
this.options.commit ? 'commit' : 'working';
telemetry_1.telemetry.trackTestComplete({
suiteUuid: '',
duration: Date.now() - testStartTime,
filesChanged: 0,
testsGenerated: 0,
success: false,
error: errorMsg,
executionType: failureExecutionType
});
return {
success: false,
error: errorMsg
};
}
finally {
// Cleanup tunnel if it was created
if (this.tunnelManager) {
try {
await this.tunnelManager.disconnectAll();
system_logger_1.systemLogger.info('✓ Tunnels cleaned up');
}
catch (error) {
system_logger_1.systemLogger.warn('⚠ Failed to cleanup tunnels: ' + error);
}
}
}
}
/**
* Run GitHub App-based PR test - sends single request with PR number
* Backend handles all git analysis via GitHub App integration
*/
async runGitHubAppPRTest() {
try {
// Get current branch name
const branchInfo = await this.gitAnalyzer.getCurrentBranchInfo();
system_logger_1.systemLogger.info(`Submitting PR #${this.options.pr} for GitHub App-based testing`, { category: 'test' });
system_logger_1.systemLogger.info(`Branch: ${branchInfo.branch}`, { category: 'git' });
// Create test request for GitHub App PR testing
const testRequest = {
type: 'pull_request',
repoName: this.gitAnalyzer.getRepoName(),
repoPath: this.options.repoPath,
branch: branchInfo.branch,
pr_number: this.options.pr,
commitHash: branchInfo.commitHash,
testDescription: `Automated E2E tests for PR #${this.options.pr}`
};
// Add tunnel configuration if applicable
if (this.options.tunnelUrl) {
testRequest.tunnelUrl = this.options.tunnelUrl;
}
if (this.options.tunnelMetadata) {
testRequest.tunnelMetadata = this.options.tunnelMetadata;
}
if (this.options.tunnelKey) {
testRequest.tunnelKey = this.options.tunnelKey;
}
// Submit test request
system_logger_1.systemLogger.info('Submitting PR test request to backend', { category: 'api' });
const createResult = await this.client.createCommitTestSuite(testRequest);
if (!createResult.success || !createResult.testSuiteUuid) {
throw new Error(createResult.error || 'Failed to create test suite');
}
const suiteUuid = createResult.testSuiteUuid;
system_logger_1.systemLogger.info(`Test suite created: ${suiteUuid}`, { category: 'test' });
// Wait for test completion
system_logger_1.systemLogger.info('Waiting for test execution', { category: 'test' });
const suite = await this.client.waitForCommitTestSuiteCompletion(suiteUuid, {
maxWaitTime: this.options.maxTestWaitTime || 600000,
pollInterval: 5000
});
if (!suite) {
throw new Error('Test suite failed or timed out');
}
// Download artifacts if requested
let downloadedFiles = [];
if (this.options.downloadArtifacts && suite.status === 'completed') {
system_logger_1.systemLogger.info('Downloading test artifacts', { category: 'test' });
downloadedFiles = await this.saveTestArtifacts(suite);
}
system_logger_1.systemLogger.success(`GitHub App PR test completed for PR #${this.options.pr}`);
const result = {
success: true,
suiteUuid,
suite,
testFiles: downloadedFiles
};
// Add optional fields only if they have values
if (this.options.tunnelKey) {
result.tunnelKey = this.options.tunnelKey;
}
if (this.tunnelInfo) {
result.tunnelInfo = this.tunnelInfo;
}
return result;
}
catch (error) {
const errorMsg = error.message || 'Unknown error occurred';
system_logger_1.systemLogger.error(`GitHub App PR test failed: ${errorMsg}`);
return {
success: false,
error: errorMsg
};
}
}
/**
* Run PR commit sequence tests - sends individual test requests for each commit
*/
async runPRCommitSequenceTests() {
try {
// Step 1: Analyze PR commit sequence
const prSequence = await this.gitAnalyzer.analyzePRCommitSequence(this.options.baseBranch, this.options.headBranch);
if (!prSequence || prSequence.commits.length === 0) {
system_logger_1.systemLogger.warn('No PR commits found to test');
return {
success: true,
testFiles: [],
prSequenceResults: [],
totalCommitsTested: 0
};
}
system_logger_1.systemLogger.info(`Found ${prSequence.totalCommits} commits to test sequentially`, { category: 'test' });
system_logger_1.systemLogger.info(`PR: ${prSequence.baseBranch} <- ${prSequence.headBranch}`, { category: 'git' });
const sequenceResults = [];
const allTestFiles = [];
let anyFailed = false;
// Process each commit individually
for (const commit of prSequence.commits) {
system_logger_1.systemLogger.info(`\n--- Testing Commit ${commit.order}/${prSequence.totalCommits} ---`, { category: 'test' });
system_logger_1.systemLogger.info(`Commit: ${commit.hash.substring(0, 8)} - ${commit.message}`, { category: 'git' });
system_logger_1.systemLogger.info(`Author: ${commit.author}`, { category: 'git' });
system_logger_1.systemLogger.info(`Changes: ${commit.changes.length} files`, { category: 'git' });
try {
// Create test request for this specific commit
const result = await this.createCommitTestSuite(commit, prSequence);
if (result.success && result.suiteUuid) {
// Wait for completion
const completedSuite = await this.client.waitForCommitTestSuiteCompletion(result.suiteUuid, {
maxWaitTime: this.options.maxTestWaitTime || 600000
});
if (completedSuite) {
// Download artifacts if enabled
let testFiles = [];
if (this.options.downloadArtifacts) {
testFiles = await this.saveTestArtifacts(completedSuite);
allTestFiles.push(...testFiles);
}
sequenceResults.push({
commitHash: commit.hash,
commitMessage: commit.message,
commitOrder: commit.order,
suiteUuid: result.suiteUuid,
success: true,
testFiles
});
system_logger_1.systemLogger.success(`✓ Commit ${commit.order} tests completed`);
}
else {
// Test suite timed out
sequenceResults.push({
commitHash: commit.hash,
commitMessage: commit.message,
commitOrder: commit.order,
suiteUuid: result.suiteUuid,
success: false,
error: 'Test suite timed out'
});
anyFailed = true;
system_logger_1.systemLogger.error(`✗ Commit ${commit.order} tests timed out`);
}
}
else {
// Test suite creation failed
sequenceResults.push({
commitHash: commit.hash,
commitMessage: commit.message,
commitOrder: commit.order,
suiteUuid: '',
success: false,
error: result.error || 'Failed to create test suite'
});
anyFailed = true;
system_logger_1.systemLogger.error(`✗ Commit ${commit.order} test creation failed: ${result.error}`);
}
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
sequenceResults.push({
commitHash: commit.hash,
commitMessage: commit.message,
commitOrder: commit.order,
suiteUuid: '',
success: false,
error: errorMsg
});
anyFailed = true;
system_logger_1.systemLogger.error(`✗ Commit ${commit.order} failed: ${errorMsg}`);
}
}
// Report overall results
const successCount = sequenceResults.filter(r => r.success).length;
system_logger_1.systemLogger.info(`\n=== PR Commit Sequence Results ===`, { category: 'test' });
system_logger_1.systemLogger.info(`Total commits tested: ${prSequence.totalCommits}`, { category: 'test' });
system_logger_1.systemLogger.info(`Successful: ${successCount}`, { category: 'test' });
system_logger_1.systemLogger.info(`Failed: ${prSequence.totalCommits - successCount}`, { category: 'test' });
if (this.options.downloadArtifacts && allTestFiles.length > 0) {
system_logger_1.systemLogger.success(`Generated ${allTestFiles.length} total test files across all commits`);
}
const firstSuiteUuid = sequenceResults.length > 0 && sequenceResults[0] ? sequenceResults[0].suiteUuid : undefined;
return {
success: !anyFailed,
testFiles: allTestFiles,
prSequenceResults: sequenceResults,
totalCommitsTested: prSequence.totalCommits,
suiteUuid: firstSuiteUuid
};
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
system_logger_1.systemLogger.error(`PR sequence testing failed: ${errorMsg}`);
return {
success: false,
error: errorMsg,
prSequenceResults: [],
totalCommitsTested: 0
};
}
}
/**
* Create a test suite for a specific commit in a PR sequence
*/
async createCommitTestSuite(commit, prSequence) {
// Get repository information
const repoName = this.gitAnalyzer.getRepoName();
// Create test description specific to this commit
const testDescription = await this.createCommitTestDescription(commit, prSequence);
const testRequest = {
repoName,
repoPath: this.options.repoPath,
branchName: prSequence.headBranch,
commitHash: commit.hash,
workingChanges: commit.changes.map((change) => ({
status: change.status,
file: change.file,
diff: change.diff
})),
testDescription,
...(prSequence.prNumber && { prNumber: prSequence.prNumber }),
// Add PR context metadata
prContext: {
baseBranch: prSequence.baseBranch,
headBranch: prSequence.headBranch,
commitOrder: commit.order,
totalCommits: prSequence.totalCommits,
isSequentialTest: true
}
};
// Add tunnel configuration if available
if (this.options.tunnelKey) {
testRequest.tunnelKey = this.options.tunnelKey;
}
if (this.options.createTunnel) {
testRequest.createTunnel = true;
testRequest.tunnelPort = this.options.tunnelPort || 3000;
}
return await this.client.createCommitTestSuite(testRequest);
}
/**
* Create a test description for a specific commit in a PR sequence
*/
async createCommitTestDescription(commit, prSequence) {
const changeTypes = this.analyzeFileTypes(commit.changes);
// Count changes by type
const componentCount = changeTypes.filter(t => t.type === 'component').reduce((sum, t) => sum + t.count, 0);
const routingCount = changeTypes.filter(t => t.type === 'routing').reduce((sum, t) => sum + t.count, 0);
const configCount = changeTypes.filter(t => t.type === 'configuration').reduce((sum, t) => sum + t.count, 0);
const stylingCount = changeTypes.filter(t => t.type === 'styling').reduce((sum, t) => sum + t.count, 0);
const otherCount = changeTypes.filter(t => !['component', 'routing', 'configuration', 'styling'].includes(t.type)).reduce((sum, t) => sum + t.count, 0);
return `Sequential PR Test - Commit ${commit.order}/${prSequence.totalCommits}
Commit: ${commit.hash.substring(0, 8)} - ${commit.message}
Author: ${commit.author}
Branch: ${prSequence.baseBranch} <- ${prSequence.headBranch}
Changes in this commit:
${commit.changes.map((c) => `- [${c.status}] ${c.file}`).join('\n')}
Change Summary:
- ${commit.changes.length} file${commit.changes.length !== 1 ? 's' : ''} modified
- Components: ${componentCount}
- Routing: ${routingCount}
- Configuration: ${configCount}
- Styling: ${stylingCount}
- Other: ${otherCount}
Focus: Test the specific functionality changes introduced by this individual commit in the sequence.`;
}
/**
* Wait for the local development server to be ready
*/
async waitForServer(port = 3000, maxWaitTime = 60000) {
const startTime = Date.now();
const pollInterval = 2000;
system_logger_1.systemLogger.info(`Waiting for server on port ${port}`, { category: 'server' });
while (Date.now() - startTime < maxWaitTime) {
try {
// Simple HTTP check to see if server is responding
const response = await fetch(`http://localhost:${port}`, {
method: 'GET',
signal: AbortSignal.timeout(5000)
});
if (response.ok || response.status === 404) {
system_logger_1.systemLogger.success(`Server is ready on port ${port}`);
return true;
}
}
catch (error) {
// Server not ready yet, continue waiting
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
system_logger_1.systemLogger.error(`Server on port ${port} did not start within ${maxWaitTime}ms`);
return false;
}
/**
* Analyze git changes (working changes, specific commit, or commit range)
*/
async analyzeChanges() {
// Priority order: explicit options > environment variables > working changes
// 1. Check for explicit commit hash option
if (this.options.commit) {
return await this.gitAnalyzer.getCommitChanges(this.options.commit);
}
// 2. Check for commit range option
if (this.options.commitRange) {
const commitHashes = await this.gitAnalyzer.getCommitsFromRange(this.options.commitRange);
return await this.gitAnalyzer.getCombinedCommitChanges(commitHashes);
}
// 3. Check for since date option
if (this.options.since) {
const commitHashes = await this.gitAnalyzer.getCommitsSince(this.options.since);
return await this.gitAnalyzer.getCombinedCommitChanges(commitHashes);
}
// 4. Check for last N commits option
if (this.options.last) {
const commitHashes = await this.gitAnalyzer.getLastCommits(this.options.last);
return await this.gitAnalyzer.getCombinedCommitChanges(commitHashes);
}
// 5. In CI/CD, check for environment variable (GitHub Actions)
const envCommitHash = process.env.GITHUB_SHA;
if (envCommitHash) {
// Analyze specific commit (typical for push events)
return await this.gitAnalyzer.getCommitChanges(envCommitHash);
}
// 6. Default: analyze working changes (for local development)
return await this.gitAnalyzer.getWorkingChanges();
}
/**
* Create a comprehensive test description based on changes
*/
async createTestDescription(changes) {
const commitHash = changes.branchInfo.commitHash;
const branch = changes.branchInfo.branch;
const fileCount = changes.changes.length;
// Use enhanced context analysis inspired by backend architecture
const contextAnalysis = await this.gitAnalyzer.analyzeChangesWithContext(changes.changes);
// Determine the source of changes for better description
let sourceDescription;
if (this.options.commit) {
sourceDescription = `specific commit ${this.options.commit.substring(0, 8)}`;
}
else if (this.options.commitRange) {
sourceDescription = `commit range ${this.options.commitRange}`;
}
else if (this.options.since) {
sourceDescription = `commits since ${this.options.since}`;
}
else if (this.options.last) {
sourceDescription = `last ${this.options.last} commit${this.options.last > 1 ? 's' : ''}`;
}
else if (process.env.GITHUB_SHA) {
sourceDescription = `CI commit ${commitHash.substring(0, 8)}`;
}
else {
sourceDescription = `working changes`;
}
// Build focused description based on analysis
let description = `Generate comprehensive E2E tests for the ${sourceDescription} on branch ${branch}.
Change Analysis:
- Total Files: ${fileCount}
- Complexity: ${contextAnalysis.changeComplexity.toUpperCase()}
- Languages: ${contextAnalysis.affectedLanguages.join(', ')}`;
// Add specific areas of focus based on changes
if (contextAnalysis.suggestedFocusAreas.length > 0) {
description += `
Focus Areas:
${contextAnalysis.suggestedFocusAreas.map(area => `- ${area}`).join('\n')}`;
}
// Add component-specific context
if (contextAnalysis.componentChanges.length > 0) {
description += `
Components Changed:
${contextAnalysis.componentChanges.slice(0, 5).map(file => `- ${file}`).join('\n')}${contextAnalysis.componentChanges.length > 5 ? '\n- ...' : ''}`;
}
// Add routing context
if (contextAnalysis.routingChanges.length > 0) {
description += `
Routing Changes:
${contextAnalysis.routingChanges.map(file => `- ${file}`).join('\n')}`;
}
// Add configuration context
if (contextAnalysis.configChanges.length > 0) {
description += `
Configuration Changes:
${contextAnalysis.configChanges.map(file => `- ${file}`).join('\n')}`;
}
description += `
Test Requirements:
1. Generate Playwright tests focused on the identified change areas
2. Test both positive and negative scenarios for modified functionality
3. Include edge cases and error handling for ${contextAnalysis.changeComplexity} complexity changes
4. Focus testing on: ${contextAnalysis.suggestedFocusAreas.slice(0, 3).join(', ')}
5. Ensure tests cover the interaction between changed ${contextAnalysis.affectedLanguages.join(' and ')} components`;
return description;
}
/**
* Analyze file types in the changes
*/
analyzeFileTypes(files) {
const typeMap = new Map();
for (const file of files) {
const ext = path.extname(file).toLowerCase();
let type;
switch (ext) {
case '.ts':
case '.tsx':
type = 'TypeScript';
break;
case '.js':
case '.jsx':
type = 'JavaScript';
break;
case '.py':
type = 'Python';
break;
case '.java':
type = 'Java';
break;
case '.css':
case '.scss':
case '.sass':
type = 'Stylesheets';
break;
case '.html':
type = 'HTML';
break;
case '.json':
type = 'Configuration';
break;
case '.md':
type = 'Documentation';
break;
default:
if (file.includes('test') || file.includes('spec')) {
type = 'Tests';
}
else if (file.includes('config') || file.includes('package')) {
type = 'Configuration';
}
else {
type = 'Other';
}
}
if (!typeMap.has(type)) {
typeMap.set(type, []);
}
typeMap.get(type).push(file);
}
return Array.from(typeMap.entries()).map(([type, files]) => ({
type,
count: files.length,
files
}));
}
/**
* Save test artifacts (scripts, recordings, etc.) to local directory
*/
async saveTestArtifacts(suite) {
const savedFiles = [];
if (!suite.tests || suite.tests.length === 0) {
system_logger_1.systemLogger.debug('No tests found in suite for artifact saving', { category: 'artifact' });
return savedFiles;
}
system_logger_1.systemLogger.debug(`Starting to save artifacts for ${suite.tests.length} tests`, { category: 'artifact' });
// Ensure test output directory exists
if (!this.options.testOutputDir) {
throw new Error('testOutputDir is undefined. This should not happen - please file a bug report.');
}
const outputDir = path.join(this.options.repoPath, this.options.testOutputDir);
await fs.ensureDir(outputDir);
for (const test of suite.tests) {
if (!test.curRun) {
system_logger_1.systemLogger.debug(`Skipping test ${test.name || test.uuid} - no curRun data`, { category: 'artifact' });
continue;
}
const testName = test.name || `test-${test.uuid?.substring(0, 8)}`;
const testDir = path.join(outputDir, testName);
await fs.ensureDir(testDir);
system_logger_1.systemLogger.debug(`Processing test: ${testName}`, {
category: 'artifact',
details: {
hasScript: !!test.curRun.runScript,
hasGif: !!test.curRun.runGif,
hasJson: !!test.curRun.runJson,
testDir: path.relative(this.options.repoPath, testDir)
}
});
// Save test script
if (test.curRun.runScript) {
try {
const scriptPath = path.join(testDir, `${testName}.spec.js`);
// For scripts, we need to replace tunnel URLs with localhost - use originalBaseUrl like recordingHandler
// Fallback to port 3000 if tunnelPort is not set
const port = this.options.tunnelPort || 3000;
const originalBaseUrl = `http://localhost:${port}`;
system_logger_1.systemLogger.debug(`Downloading script for ${testName}`, {
category: 'artifact',
details: {
url: test.curRun.runScript,
targetPath: path.relative(this.options.repoPath, scriptPath),
originalBaseUrl
}
});
const success = await this.client.downloadArtifactToFile(test.curRun.runScript, scriptPath, originalBaseUrl);
system_logger_1.systemLogger.debug(`Script download result for ${testName}: ${success}`, { category: 'artifact' });
if (success) {
savedFiles.push(scriptPath);
system_logger_1.systemLogger.info(`✓ Saved test script: ${path.relative(this.options.repoPath, scriptPath)}`);
}
else {
system_logger_1.systemLogger.warn(`⚠ Script download failed for ${testName} - no file saved`);
}
}
catch (error) {
system_logger_1.systemLogger.warn(`⚠ Failed to download script for ${testName}: ${error}`);
}
}
// Save test recording (GIF)
if (test.curRun.runGif) {
try {
const gifPath = path.join(testDir, `${testName}-recording.gif`);
system_logger_1.systemLogger.debug(`Downloading GIF for ${testName}`, {
category: 'artifact',
details: {
url: test.curRun.runGif,
targetPath: path.relative(this.options.repoPath, gifPath)
}
});
const success = await this.client.downloadArtifactToFile(test.curRun.runGif, gifPath);
system_logger_1.systemLogger.debug(`GIF download result for ${testName}: ${success}`, { category: 'artifact' });
if (success) {
savedFiles.push(gifPath);
system_logger_1.systemLogger.info(`✓ Saved test recording: ${path.relative(this.options.repoPath, gifPath)}`);
}
else {
system_logger_1.systemLogger.warn(`⚠ GIF download failed for ${testName} - no file saved`);
}
}
catch (error) {
system_logger_1.systemLogger.warn(`⚠ Failed to download recording for ${testName}: ${error}`);
}
}
// Save test details (JSON)
if (test.curRun.runJson) {
try {
const jsonPath = path.join(testDir, `${testName}-details.json`);
system_logger_1.systemLogger.debug(`Downloading JSON for ${testName}`, {
category: 'artifact',
details: {
url: test.curRun.runJson,
targetPath: path.relative(this.options.repoPath, jsonPath)
}
});
const success = await this.client.downloadArtifactToFile(test.curRun.runJson, jsonPath);
system_logger_1.systemLogger.debug(`JSON download result for ${testName}: ${success}`, { category: 'artifact' });
if (success) {
savedFiles.push(jsonPath);
system_logger_1.systemLogger.info(`✓ Saved test details: ${path.relative(this.options.repoPath, jsonPath)}`);
}
else {
system_logger_1.systemLogger.warn(`⚠ JSON download failed for ${testName} - no file saved`);
}
}
catch (error) {
system_logger_1.systemLogger.warn(`⚠ Failed to download details for ${testName}: ${error}`);
}
}
}
system_logger_1.systemLogger.debug(`Artifact saving completed. Total files saved: ${savedFiles.length}`, {
category: 'artifact',
details: {
savedFiles: savedFiles.map(f => path.relative(this.options.repoPath, f))
}
});
return savedFiles;
}
/**
* Report test results to console
*/
reportResults(suite) {
// Use systemLogger's displayResults which handles both dev and user modes
system_logger_1.systemLogger.displayResults(suite);
// Set exit code for CI/CD based on test outcomes
if (suite.tests && suite.tests.length > 0) {
const failed = suite.tests.filter((t) => t.curRun?.outcome === 'fail').length;
if (failed > 0) {
process.exitCode = 1; // Set non-zero exit code for CI/CD
}
}
}
/**
* Get colored status text
*/
getStatusColor(status) {
switch (status) {
case 'completed':
return chalk_1.default.green('✓ PASSED');
case 'failed':
return chalk_1.default.red('✗ FAILED');
case 'running':
return chalk_1.default.yellow('⏳ RUNNING');
case 'pending':
return chalk_1.default.blue('⏸ PENDING');
default:
return chalk_1.default.gray('❓ UNKNOWN');
}
}
}
exports.TestManager = TestManager;
//# sourceMappingURL=test-manager.js.map