@labnex/cli
Version:
CLI for Labnex, an AI-Powered Testing Automation Platform
859 lines • 55 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.LocalBrowserExecutor = void 0;
const puppeteer_1 = __importDefault(require("puppeteer"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const testStepParser_1 = require("./testStepParser"); // CLI version of parser
const actionHandlers = __importStar(require("./lib/actionHandlers"));
const client_1 = require("./api/client"); // Assuming apiClient will be enhanced or used for AI
class LocalBrowserExecutor {
constructor(options = {}) {
this.browser = null;
this.page = null;
this.currentFrame = null;
this.logs = [];
this.headlessMode = false;
this.aiOptimizationEnabled = false; // Added AI flag
this.baseUrlGlobal = ''; // New persistent baseUrl across steps
this.addLog = (message, data) => {
const logMessage = data ? `${message} ${JSON.stringify(data).substring(0, 100)}...` : message;
console.log(`[LBE] ${logMessage}`);
this.logs.push(`[${new Date().toISOString().substring(0, 19).replace('T', ' ')}] ${logMessage}`);
const logFilePath = path.join(process.cwd(), 'test_logs.txt');
try {
fs.appendFileSync(logFilePath, `[${new Date().toISOString().substring(0, 19).replace('T', ' ')}] ${logMessage}\n`);
}
catch (error) {
if (error instanceof Error) {
console.error(`[LBE] Failed to write to log file: ${error.message}`);
}
}
};
this.headlessMode = options.headless !== undefined ? options.headless : false;
this.aiOptimizationEnabled = options.aiOptimizationEnabled || false;
this.addLog(`AI Optimization Enabled: ${this.aiOptimizationEnabled}`);
}
async initialize() {
this.logs = []; // Clear logs on initialization
this.addLog('Initializing browser...');
try {
this.browser = await puppeteer_1.default.launch({
headless: this.headlessMode,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--window-size=1920,1080',
'--start-maximized',
],
defaultViewport: null,
protocolTimeout: 300000, // 5 minutes
});
this.page = await this.browser.newPage();
await this.page.setDragInterception(true);
this.currentFrame = this.page;
await this.page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36');
this.addLog('Browser initialized successfully.');
}
catch (error) {
if (error instanceof Error) {
this.addLog('Error initializing browser:', { message: error.message });
throw error;
}
throw new Error('Unknown error initializing browser');
}
}
async executeTestCase(testCaseId, stepDescriptions, overallExpectedResult, baseUrl = '', testCaseTitle) {
if (!this.browser || !this.page) {
await this.initialize();
}
if (!this.page) {
throw new Error('Page not initialized');
}
if (this.page && !this.currentFrame) {
this.currentFrame = this.page;
}
if (!this.page || !this.currentFrame) {
throw new Error('Page or currentFrame not initialized');
}
const startTime = Date.now();
this.addLog(`Starting test case: ${testCaseId}`);
this.addLog(`[AI] Resetting context for new test case ${testCaseId}.`);
// Persist provided baseUrl for later heuristic use
if (baseUrl) {
this.baseUrlGlobal = baseUrl;
}
// Check if test case likely targets saucedemo.com and does not include login steps
let modifiedStepDescriptions = [...stepDescriptions];
const isEcommerceTest = (stepDescriptions.some(step => step.toLowerCase().includes('e-commerce')) || (testCaseTitle && testCaseTitle.toLowerCase().includes('e-commerce')));
const hasLoginStep = stepDescriptions.some(step => step.toLowerCase().includes('login') || step.toLowerCase().includes('enter') || step.toLowerCase().includes('username') || step.toLowerCase().includes('password'));
if (isEcommerceTest && !hasLoginStep) {
this.addLog(`[Auto-Login] Detected e-commerce test without login steps. Prepending login steps for saucedemo.com.`);
modifiedStepDescriptions = [
'Enter "standard_user" in the username field',
'Enter "secret_sauce" in the password field',
'Click the Login button',
'Wait for the page to redirect to the inventory'
].concat(stepDescriptions.slice(1)); // Replace first step if it's just navigation
}
const { stepResults, overallStatus } = await this._executeStepsInSequence(modifiedStepDescriptions, baseUrl, overallExpectedResult);
const duration = Date.now() - startTime;
this.addLog(`Test case ${testCaseId} finished. Status: ${overallStatus}, Duration: ${duration}ms`);
return {
testCaseId,
status: overallStatus,
steps: stepResults,
duration,
logs: [...this.logs],
};
}
async _executeStepsInSequence(stepDescriptions, baseUrl, overallExpectedResult) {
const stepResults = [];
let overallStatus = 'passed';
for (let i = 0; i < stepDescriptions.length; i++) {
const stepDescription = stepDescriptions[i];
this.addLog(`Step ${i + 1}: ${stepDescription.substring(0, 50)}...`);
const result = await this._processSingleStep(stepDescription, baseUrl, i + 1, overallExpectedResult);
stepResults.push({
...result,
stepDescription: stepDescription,
stepNumber: i + 1,
});
if (result.status === 'failed') {
overallStatus = 'failed';
this.addLog(`Step ${i + 1} failed: ${result.message?.substring(0, 100)}...`);
break;
}
}
return { stepResults, overallStatus };
}
async _processSingleStep(stepDescription, baseUrl, stepNumber, overallExpectedResult, attempt = 0 // 0 for original, 1+ for AI suggested retry
) {
const stepStartTime = Date.now();
let result = { status: 'failed', message: 'Not executed yet', duration: 0 };
const MAX_AI_SUGGESTION_ATTEMPTS = 2; // Max AI retries
const MAX_RETRY_ATTEMPTS = 3; // Max API call retries
const BASE_RETRY_DELAY_MS = 1500;
let stepDescriptionToParse = stepDescription;
let initialStepObject = null;
if (attempt === 0) {
this.addLog(`[AI] Parsing/Interpreting original step ${stepNumber}: "${stepDescription.substring(0, 70)}..."`);
try {
initialStepObject = testStepParser_1.TestStepParser.parseStep(stepDescription);
if (!initialStepObject.action && this.aiOptimizationEnabled) {
this.addLog(`[AI] Initial parsing failed to identify action for step ${stepNumber}. Attempting AI interpretation.`);
this.addLog('[AI] Calling apiClient.interpretTestStep with description:', {
description: stepDescription,
});
const interpretResponse = await this.retryApiCall(() => client_1.apiClient.interpretTestStep(stepDescription), MAX_RETRY_ATTEMPTS, BASE_RETRY_DELAY_MS, `interpret step ${stepNumber}`);
this.addLog('[AI] apiClient.interpretTestStep response:', interpretResponse);
if (interpretResponse.success && interpretResponse.data) {
// Use first alternative if multiple separated by " or "
const interpretedStepString = interpretResponse.data.split(/\s+or\s+/i)[0].trim();
this.addLog(`[AI] Interpreted step ${stepNumber} as: "${interpretedStepString.substring(0, 70)}..."`);
stepDescriptionToParse = interpretedStepString;
initialStepObject = testStepParser_1.TestStepParser.parseStep(stepDescriptionToParse);
}
else {
this.addLog(`[AI] Interpretation failed for step ${stepNumber}. Error: ${interpretResponse.error || 'Unknown'}. Proceeding with original.`);
}
}
}
catch (parseError) {
if (parseError instanceof Error) {
this.addLog(`[Error] Parsing step "${stepDescriptionToParse}" failed: ${parseError.message}`);
result = {
status: 'failed',
message: `Failed to parse step: ${parseError.message}`,
duration: Date.now() - stepStartTime,
};
return result;
}
result = {
status: 'failed',
message: 'Unknown parsing error',
duration: Date.now() - stepStartTime,
};
return result;
}
}
else {
// AI retry
this.addLog(`[AI] Attempt ${attempt}: Retrying with AI suggested step: "${stepDescriptionToParse.substring(0, 70)}..."`);
initialStepObject = testStepParser_1.TestStepParser.parseStep(stepDescriptionToParse);
// Log the parsed result to verify target extraction
this.addLog(`[AI Retry Parse Result] Action: ${initialStepObject.action || 'N/A'}, Target: ${initialStepObject.target || 'N/A'}, Value: ${initialStepObject.value || 'N/A'}`);
// Fallback: If action is 'type' and target is missing, try to extract manually from common AI format
if (initialStepObject.action === 'type' && !initialStepObject.target) {
const typePattern = /type\s+\(([^)]+)\)\s+with\s+value\s+(['"])(.*?)\2/i;
const typeMatch = stepDescriptionToParse.match(typePattern);
if (typeMatch && typeMatch[1] && typeMatch[3]) {
this.addLog(`[AI Retry Fallback] Manually extracted selector: ${typeMatch[1]}, value: ${typeMatch[3]}`);
initialStepObject.target = typeMatch[1].trim();
initialStepObject.value = typeMatch[3];
}
else {
this.addLog(`[AI Retry Fallback] Manual extraction failed for: ${stepDescriptionToParse}`);
}
}
}
// Heuristic: If the parsed step is a navigation with no explicit URL, attempt to derive one.
if (initialStepObject && initialStepObject.action === 'navigate') {
let navTarget = initialStepObject.target || '';
const promptForBaseUrl = async () => {
const inquirer = await Promise.resolve().then(() => __importStar(require('inquirer')));
const answer = await inquirer.default.prompt([
{
type: 'input',
name: 'base',
message: 'Base URL (e.g., https://example.com):',
validate: (input) => /^https?:\/\//i.test(input) || 'Please enter a valid http(s) URL',
},
]);
return answer.base;
};
// Helper to slugify a page name -> "/login"
const pageNameToPath = (name) => {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-page$/, '') // remove trailing "-page" if present
.replace(/-screen$/, '') // common synonyms
.replace(/-view$/, '');
if (slug === 'login') {
return '';
}
return `/${slug || ''}`;
};
if (!navTarget) {
// Pattern: "navigate to the login page" or "navigate to login page"
const pageMatch = stepDescription.match(/navigate\s+to\s+(?:the\s+)?([a-zA-Z0-9\s-]+?)(?:\s+page)?(?:\s|$)/i);
if (pageMatch && pageMatch[1]) {
navTarget = pageNameToPath(pageMatch[1]);
}
// Pattern: "via src/components/Login.tsx" – extract file name
const viaMatch = stepDescription.match(/via\s+([^\s]+\.(?:tsx?|jsx?|html?))/i);
if (!navTarget && viaMatch && viaMatch[1]) {
const fileName = viaMatch[1].split(/[\\/]/).pop() || '';
const nameWithoutExt = fileName.replace(/\.[^.]+$/, '');
navTarget = pageNameToPath(nameWithoutExt);
}
}
// Determine the effective base URL (priority: provided => persisted => prompt)
let effectiveBase = baseUrl || this.baseUrlGlobal;
if (!effectiveBase) {
// Interactive prompt (only first time)
this.addLog('[Base URL Prompt] Asking user for base URL because it is not set.');
effectiveBase = await promptForBaseUrl();
this.baseUrlGlobal = effectiveBase; // Persist for remainder of session
}
// If navTarget is still empty or just '/', rely on effectiveBase
if (!navTarget || navTarget === '/') {
navTarget = effectiveBase;
}
else if (!/^https?:\/\//i.test(navTarget)) {
// If navTarget is relative and baseUrl provided, prefix it
if (effectiveBase) {
navTarget = `${effectiveBase.replace(/\/$/, '')}/${navTarget.replace(/^\//, '')}`;
}
}
// Update target in place so downstream execution uses the derived URL
initialStepObject.target = navTarget;
}
if (!initialStepObject || !initialStepObject.action) {
const message = `Step ${stepNumber} could not be parsed or action not identified: "${stepDescriptionToParse}"`;
this.addLog(`[Error] ${message}`);
result = { status: 'failed', message, duration: Date.now() - stepStartTime };
return result;
}
const currentStepObject = initialStepObject;
try {
let actionDescription = currentStepObject.action;
if (currentStepObject.target) {
actionDescription += ` on "${String(currentStepObject.target).substring(0, 50)}"`;
}
if (currentStepObject.value) {
actionDescription += ` with value "${String(currentStepObject.value).substring(0, 30)}"`;
}
this.addLog(`[Step] Executing (Attempt ${attempt}): ${actionDescription}`);
const disableFallbacks = attempt < MAX_AI_SUGGESTION_ATTEMPTS || !this.aiOptimizationEnabled;
result = await this.executeStep(currentStepObject, overallExpectedResult, disableFallbacks);
result.duration = Date.now() - stepStartTime;
this.addLog(`[Step] Result for "${currentStepObject.action}" (Attempt ${attempt}): ${result.status}, Message: ${result.message?.substring(0, 100)}`);
}
catch (error) {
if (error instanceof Error) {
this.addLog(`[Step] Error executing step ${stepNumber} ("${currentStepObject.action}") (Attempt ${attempt}): ${error.message.substring(0, 150)}`);
result = {
status: 'failed',
message: error.message,
duration: Date.now() - stepStartTime,
};
}
else {
result = {
status: 'failed',
message: 'Unknown execution error',
duration: Date.now() - stepStartTime,
};
}
}
if (result.status === 'failed' &&
this.aiOptimizationEnabled &&
attempt < MAX_AI_SUGGESTION_ATTEMPTS) {
this.addLog(`[AI] Step ${stepNumber} failed (Action: "${currentStepObject.action}", Target: "${currentStepObject.target || 'N/A'}"). Attempting AI suggestion (${attempt + 1}/${MAX_AI_SUGGESTION_ATTEMPTS}).`);
const originalActionWasNavigate = currentStepObject.originalStep?.toLowerCase().startsWith('navigate to ') ||
stepDescription.toLowerCase().startsWith('navigate to ');
if (originalActionWasNavigate) {
this.addLog('[AI] Failed action appears to be "navigate". Skipping AI selector suggestion for URL issues.');
return result;
}
if (!this.page) {
this.addLog('[AI] Page not available for capturing DOM context. Skipping AI suggestion.');
return result;
}
let detailedPageContext = 'DOM snapshot unavailable';
try {
this.addLog('[AI] Capturing DOM context for suggestion...');
const failedSelectorForContext = typeof currentStepObject.target === 'string' ? currentStepObject.target : null;
detailedPageContext = await this.page.evaluate((passedFailedSelector) => {
const MAX_ELEMENTS_CTX = 50;
const MAX_ATTR_LENGTH_CTX = 30;
const MAX_TEXT_LENGTH_CTX = 50;
const MAX_TOTAL_STRING_LENGTH = 5000;
function truncateCtx(str, len) {
if (!str)
return '';
return str.length > len ? str.substring(0, len - 3) + '...' : str;
}
let elementCount = 0;
function elementToStringCtx(el) {
if (!el || elementCount >= MAX_ELEMENTS_CTX)
return '';
elementCount++;
const tagName = el.tagName.toLowerCase();
let attrs = '';
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i];
if (attr.name === 'style' ||
attr.name.startsWith('on') ||
attr.name === 'd' ||
attr.name === 'stroke-width' ||
(attr.name.startsWith('aria-') && attr.value.length > 70)) {
continue;
}
attrs += ` ${attr.name}="${truncateCtx(attr.value, MAX_ATTR_LENGTH_CTX)}"`;
}
let childrenString = '';
if (el.children.length > 0) {
for (let i = 0; i < el.children.length; i++) {
if (elementCount >= MAX_ELEMENTS_CTX)
break;
childrenString += elementToStringCtx(el.children[i]);
}
}
let textContent = '';
if ((!el.children.length || childrenString.length === 0) && el.childNodes.length > 0) {
let directText = '';
for (let i = 0; i < el.childNodes.length; i++) {
const childNode = el.childNodes[i];
if (childNode.nodeType === Node.TEXT_NODE &&
childNode.textContent?.trim()) {
directText += childNode.textContent.trim() + ' ';
}
}
if (directText.trim()) {
textContent = truncateCtx(directText.trim(), MAX_TEXT_LENGTH_CTX);
}
}
if (!childrenString &&
!textContent &&
[
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'a',
'button',
'label',
'td',
'th',
'li',
].includes(tagName)) {
const ownText = el.innerText?.trim();
if (ownText) {
textContent = truncateCtx(ownText, MAX_TEXT_LENGTH_CTX * 2);
}
}
return `<${tagName}${attrs}>${textContent}${childrenString}</${tagName}>`;
}
let rootElement = null;
if (passedFailedSelector &&
typeof passedFailedSelector === 'string' &&
passedFailedSelector.toLowerCase().startsWith('(css:')) {
const cssSelector = passedFailedSelector.substring(5, passedFailedSelector.length - 1).trim();
if (cssSelector) {
const parts = cssSelector.split(/\s+/);
for (let i = parts.length - 1; i > 0; i--) {
const candidateSelector = parts.slice(0, i).join(' ');
if (candidateSelector.trim() === '>' ||
candidateSelector.trim() === '+' ||
candidateSelector.trim() === '~' ||
candidateSelector.trim() === '') {
continue;
}
try {
const el = document.querySelector(candidateSelector);
if (el) {
rootElement = el;
break;
}
}
catch {
// ignore invalid intermediate selector
}
}
}
}
if (!rootElement) {
rootElement = document.querySelector('main') || document.body;
}
elementCount = 0;
let fullHtml = elementToStringCtx(rootElement);
if (fullHtml.length > MAX_TOTAL_STRING_LENGTH) {
const lastClosingTag = fullHtml.lastIndexOf('</', MAX_TOTAL_STRING_LENGTH);
if (lastClosingTag !== -1) {
const endOfTag = fullHtml.indexOf('>', lastClosingTag);
if (endOfTag !== -1 && endOfTag <= MAX_TOTAL_STRING_LENGTH + 30) {
fullHtml = fullHtml.substring(0, endOfTag + 1);
}
else {
fullHtml = fullHtml.substring(0, MAX_TOTAL_STRING_LENGTH);
}
}
else {
fullHtml = fullHtml.substring(0, MAX_TOTAL_STRING_LENGTH);
}
fullHtml += '<!-- DOM structure truncated -->';
}
return fullHtml;
}, failedSelectorForContext);
this.addLog(`[AI] Captured DOM context (length: ${detailedPageContext.length}). Preview: ${detailedPageContext
.substring(0, 100)
.replace(/\n/g, ' ')}...`);
}
catch (contextError) {
if (contextError instanceof Error) {
this.addLog(`[AI] Failed to capture DOM context: ${contextError.message.substring(0, 100)}...`);
}
detailedPageContext = `Page URL: ${this.page.url() || 'unknown'}`;
}
try {
const descriptiveTermForSuggestion = typeof currentStepObject.target === 'string'
? currentStepObject.target
: stepDescription;
this.addLog(`[AI] Requesting selector suggestion for target: "${currentStepObject.target ||
'N/A'}", descriptive: "${descriptiveTermForSuggestion.substring(0, 70)}"`);
const selectorParam = typeof currentStepObject.target === 'string' ? currentStepObject.target : '';
if (!selectorParam &&
currentStepObject.action !== 'navigate' &&
currentStepObject.action !== 'wait') {
this.addLog(`[AI] Warning: Failed step target is not a string or is empty ('${selectorParam}'). AI suggestion might be less effective.`);
}
const selectorSuggestionResponse = await this.retryApiCall(() => client_1.apiClient.getDynamicSelectorSuggestion({
failedSelector: selectorParam,
descriptiveTerm: descriptiveTermForSuggestion,
pageUrl: this.page?.url() || '',
domSnippet: detailedPageContext,
originalStep: stepDescription,
}), MAX_RETRY_ATTEMPTS, BASE_RETRY_DELAY_MS, `get dynamic selector for step ${stepNumber}, attempt ${attempt + 1}`);
if (selectorSuggestionResponse.success &&
selectorSuggestionResponse.data &&
selectorSuggestionResponse.data.suggestedSelector) {
const aiData = selectorSuggestionResponse.data;
const aiConfidence = aiData.confidence;
const aiReasoning = aiData.reasoning;
const aiSuggestedSelector = aiData.suggestedSelector;
const aiSuggestedStrategy = aiData.suggestedStrategy;
this.addLog(`[AI] Received selector suggestion: "${aiSuggestedSelector}" (Strategy: ${aiSuggestedStrategy || 'N/A'}, Confidence: ${aiConfidence || 'N/A'}, Reasoning: ${aiReasoning || 'N/A'})`);
const originalStepAction = currentStepObject.action;
const strategyToUse = aiSuggestedStrategy || 'css';
let selectorValueForHint = aiSuggestedSelector;
if (selectorValueForHint.startsWith(strategyToUse + ':')) {
selectorValueForHint = selectorValueForHint.substring((strategyToUse + ':').length);
}
if (selectorValueForHint.startsWith('css:') && strategyToUse === 'css') {
selectorValueForHint = selectorValueForHint.substring('css:'.length);
}
else if (selectorValueForHint.startsWith('xpath:') &&
strategyToUse === 'xpath') {
selectorValueForHint = selectorValueForHint.substring('xpath:'.length);
}
let escapedSelectorValue = JSON.stringify(selectorValueForHint);
if (escapedSelectorValue.startsWith('"') &&
escapedSelectorValue.endsWith('"')) {
escapedSelectorValue = escapedSelectorValue.substring(1, escapedSelectorValue.length - 1);
}
let finalAiSelectorForReconstructionInHintFormat = `(${strategyToUse}: ${escapedSelectorValue})`;
if (strategyToUse === 'xpath') {
finalAiSelectorForReconstructionInHintFormat = `(${strategyToUse}: ${escapedSelectorValue})`;
}
let aiReconstructedStepString;
if (originalStepAction === 'assert' &&
currentStepObject.target) {
const originalAssertionDetails = currentStepObject.target.match(/type="([^"]+)", selector="([^"]+)", expected="([^"]*)", condition="([^"]+)"/);
if (originalAssertionDetails) {
const [, assertType, , assertExpected, assertCondition] = originalAssertionDetails;
const aiSelectorInHint = `(type=${strategyToUse}, selector=${finalAiSelectorForReconstructionInHintFormat})`;
aiReconstructedStepString = `assert "(type=${assertType}, selector=${aiSelectorInHint}, expected=${assertExpected}, condition=${assertCondition})"`;
this.addLog(`[AI] Reconstructed assertion step (complex): ${aiReconstructedStepString}`);
}
else {
this.addLog(`[AI] Could not parse original assertion details from target: "${currentStepObject.target}". Defaulting to click.`);
aiReconstructedStepString = `click "${finalAiSelectorForReconstructionInHintFormat}"`;
}
}
else if (originalStepAction === 'dragAndDrop' &&
currentStepObject.target &&
currentStepObject.destinationTarget) {
const originalDestinationSelector = currentStepObject.destinationTarget;
aiReconstructedStepString = `drag "${finalAiSelectorForReconstructionInHintFormat}" to "${originalDestinationSelector}"`;
this.addLog(`[AI] Reconstructed dragAndDrop step: ${aiReconstructedStepString}`);
}
else {
aiReconstructedStepString = `${originalStepAction} ${finalAiSelectorForReconstructionInHintFormat}`;
if (currentStepObject.value &&
(originalStepAction === 'type' || originalStepAction === 'select')) {
aiReconstructedStepString += ` with value "${currentStepObject.value}"`;
}
}
this.addLog(`[AI] Attempt ${attempt + 1}: Retrying with AI suggested step: "${aiReconstructedStepString.substring(0, 150)}..."`);
return this._processSingleStep(aiReconstructedStepString, baseUrl, stepNumber, overallExpectedResult, attempt + 1);
}
else {
this.addLog(`[AI] Selector suggestion failed or no selector provided. Error: ${selectorSuggestionResponse.error || 'No selector in response'}. Raw: ${JSON.stringify(selectorSuggestionResponse).substring(0, 100)}`);
}
}
catch (aiError) {
if (aiError instanceof Error) {
this.addLog(`[AI] Error during selector suggestion attempt: ${aiError.message.substring(0, 150)}`);
}
}
}
return result;
}
async executeStep(parsedStep, overallTestCaseExpectedResult, disableFallbacksForAiRetry = false) {
if (!this.page || !this.currentFrame) {
throw new Error('Page or currentFrame not initialized');
}
const stepStartTime = Date.now();
let status = 'failed';
let message;
let screenshot;
let failureType;
this.addLog(`[Action] ${parsedStep.action}: ${parsedStep.target?.substring(0, 30) || 'N/A'}...`);
try {
const disableFallbacks = disableFallbacksForAiRetry || !this.aiOptimizationEnabled;
await this._dispatchStepAction(parsedStep, overallTestCaseExpectedResult, disableFallbacks);
status = 'passed';
this.addLog(`[Action] ${parsedStep.action} successful.`);
}
catch (error) {
if (error instanceof Error) {
this.addLog(`[Action] ${parsedStep.action} failed: ${error.message.substring(0, 50)}...`);
// Add warning for click actions with generic selectors
if (parsedStep.action === 'click' && parsedStep.target &&
!parsedStep.target.includes('#') && !parsedStep.target.includes('.') &&
!parsedStep.target.includes('[') && parsedStep.target.length < 20) {
this.addLog(`[Warning] The target '${parsedStep.target}' for click action may be too generic. Consider specifying a selector like '#id' or '.class' for better accuracy.`);
}
status = 'failed';
message = error.message;
// Categorize failure type
if (message.includes('not found') || message.includes('No element matching')) {
failureType = 'elementNotFound';
this.addLog('[Failure Type] Element not found.');
}
else if (message.includes('failed to perform') || message.includes('action not performed')) {
failureType = 'actionFailed';
this.addLog('[Failure Type] Action not performed as expected.');
}
else {
failureType = 'other';
this.addLog('[Failure Type] Other error during action execution.');
}
try {
screenshot = await this.page.screenshot({ encoding: 'base64' });
}
catch (screenshotError) {
if (screenshotError instanceof Error) {
this.addLog('[Action] Failed to take screenshot.', { message: screenshotError.message });
}
}
}
else {
message = 'Unknown action error';
failureType = 'other';
}
}
return {
status,
message,
screenshot,
failureType,
duration: Date.now() - stepStartTime,
};
}
async _dispatchStepAction(parsedStep, overallTestCaseExpectedResult, disableFallbacks = false) {
if (!this.page || !this.currentFrame) {
throw new Error('Page or frame not initialized for dispatching action.');
}
const { action, target, value, timeout, assertionType, assertion } = parsedStep;
this.addLog(`Dispatching action: ${action}, Target: ${target ? target.toString().substring(0, 50) : 'N/A'}, Value: ${value ? value.substring(0, 50) : 'N/A'}`);
const commonParams = {
page: this.page,
frame: this.currentFrame,
addLog: this.addLog,
parsedStep,
overallTestCaseExpectedResult,
baseUrl: '',
disableFallbacksForAiRetry: disableFallbacks,
apiClient: client_1.apiClient,
};
try {
switch (parsedStep.action) {
case 'navigate':
if (!this.page || !this.currentFrame) {
this.addLog(`[Recovery] Page/frame not initialized. Reinitializing...`);
await this.initialize();
}
if (!this.page || !this.currentFrame) {
throw new Error('Page or currentFrame still not initialized after recovery attempt.');
}
{
let navigationUrl = parsedStep.target || '';
if (!navigationUrl && parsedStep.originalStep?.toLowerCase().includes('e-commerce')) {
navigationUrl = 'https://www.saucedemo.com/';
this.addLog(`[Default URL] Using default e-commerce URL: ${navigationUrl}`);
}
if (!navigationUrl) {
throw new Error('Navigation URL not provided');
}
const newFrameContext = await actionHandlers.handleNavigate(this.page, this.currentFrame, this.addLog, navigationUrl);
if (newFrameContext) {
this.currentFrame = newFrameContext;
}
}
break;
case 'click':
this.addLog(`[Action] click: ${parsedStep.target}...`);
if (parsedStep.target && parsedStep.target.includes('Open Modal')) {
this.addLog('[W3Schools Pre-Click] Adding a wait for modal button to load.');
await new Promise((resolve) => setTimeout(resolve, 10000));
}
await actionHandlers.handleClick(this.page, this.currentFrame, this.addLog, parsedStep.target || '', 'false');
break;
case 'type':
this.addLog(`[Type Action Debug] Selector being passed to handleType: ${parsedStep.target || 'N/A'}`);
this.addLog(`[Type Action Debug] Full parsedStep object: Action=${parsedStep.action}, Target=${parsedStep.target || 'N/A'}, Value=${parsedStep.value || 'N/A'}, OriginalStep=${parsedStep.originalStep || 'N/A'}`);
await actionHandlers.handleType(this.page, this.currentFrame, this.addLog, parsedStep.target || '', parsedStep.value || '', parsedStep.originalStep || '', this.retryApiCall.bind(this));
break;
case 'wait': {
let waitArg;
if (parsedStep.target) {
waitArg = parsedStep.target;
}
else if (typeof parsedStep.timeout === 'number') {
waitArg = parsedStep.timeout;
}
await actionHandlers.handleWait(this.currentFrame || this.page, this.addLog, waitArg, typeof parsedStep.timeout === 'number' ? parsedStep.timeout : 10000);
break;
}
case 'assert':
if (!this.page) {
throw new Error('Page not initialized for assertion');
}
if (!this.currentFrame) {
throw new Error('Current frame not initialized for assertion');
}
await actionHandlers.handleAssertion(this.page, this.currentFrame, this.addLog, parsedStep);
break;
case 'select':
await actionHandlers.handleSelect(this.page, this.currentFrame, this.addLog, parsedStep.target || '', parsedStep.value || '', parsedStep.originalStep || '', this.retryApiCall.bind(this));
break;
case 'hover':
await actionHandlers.handleHover(this.page, this.currentFrame, this.addLog, parsedStep.target || '', parsedStep.originalStep || '', this.retryApiCall.bind(this));
break;
case 'scroll':
await actionHandlers.handleScroll(this.page, this.currentFrame, this.addLog, parsedStep.target || '', parsedStep.originalStep || '', this.retryApiCall.bind(this));
break;
case 'upload':
{
let uploadSelector = parsedStep.target || '';
if (parsedStep.originalStep &&
parsedStep.target &&
typeof parsedStep.target === 'string') {
const uploadPattern = /upload file.*?to element\s+"([^"]+)"/i;
const match = uploadPattern.exec(parsedStep.originalStep);
if (match && match[1]) {
const selectorFromOriginalStep = match[1];
const hasHintInOriginal = /^\((?:css|xpath|id|name|text|value|aria|placeholder|title|data):/i.test(selectorFromOriginalStep);
const hasHintInParsed = /^\((?:css|xpath|id|name|text|value|aria|placeholder|title|data):/i.test(parsedStep.target);
if (hasHintInOriginal && !hasHintInParsed) {
this.addLog(`[Upload] Using original selector: ${selectorFromOriginalStep.substring(0, 30)}...`);
uploadSelector = selectorFromOriginalStep;
}
else if (hasHintInOriginal === hasHintInParsed && selectorFromOriginalStep !== parsedStep.target) {
this.addLog(`[Upload] Original (${selectorFromOriginalStep.substring(0, 30)}...) and parsed (${parsedStep.target.substring(0, 30)}...) differ. Using parsed.`);
}
}
}
const filePathToUpload = parsedStep.filePath || (await this.searchForFile());
this.addLog(`[Upload] Attempting to upload file: ${filePathToUpload}`);
await actionHandlers.handleUpload(this.page, this.currentFrame, this.addLog, uploadSelector, filePathToUpload, parsedStep.originalStep || '', this.retryApiCall.bind(this));
this.addLog(`[Upload] Upload action completed for file: ${filePathToUpload}`);
// Validate upload success
try {
const uploadConfirmationSelector = '#uploaded-files';
await this.page.waitForSelector(uploadConfirmationSelector, {
timeout: 5000,
});
const uploadedFileName = await this.page.evaluate((sel) => {
const el = document.querySelector(sel);
return el && el.textContent ? el.textContent.trim() : '';
}, uploadConfirmationSelector);
this.addLog(`[Upload Validation] Upload confirmed. File name displayed: ${uploadedFileName}`);
}
catch (validationError) {
if (validationError instanceof Error) {
this.addLog(`[Upload Validation] Failed to confirm upload: ${validationError.message}`);
}
throw new Error(`Upload performed but confirmation not found.`);
}
}
break;
case 'dragAndDrop':
if (!target) {
throw new Error('Drag and drop requires a source selector');
}
if (!parsedStep.destinationTarget) {
throw new Error('Drag and drop requires a destination selector');
}
if (this.aiOptimizationEnabled) {
await actionHandlers.handleDragAndDropV2(this.page, this.currentFrame, this.addLog, target, parsedStep.destinationTarget, parsedStep.originalStep || '', this.retryApiCall.bind(this));
}
else {
await actionHandlers.handleDragAndDrop(this.page, this.currentFrame, this.addLog, target, parsedStep.destinationTarget, parsedStep.originalStep || '', this.retryApiCall.bind(this));
}
break;
case 'switchToIframe':
this.addLog(`[SwitchToIframe] Looking for iframe: "${parsedStep.target || 'N/A'}"`);
{
let iframeFound = false;
try {
const frames = this.page.frames();
this.addLog(`[SwitchToIframe] Total frames found: ${frames.length}`);
for (const frame of frames) {
try {
const frameElement = await frame.frameElement();
if (frameElement) {
const className = await frameElement.evaluate((el) => el.getAttribute('class') || '', frameElement);
const id = await frameElement.evaluate((el) => el.getAttribute('id') || '', frameElement);
const src = await frameElement.evaluate((el) => el.getAttribute('src') || '', frameElement);
this.addLog(`[SwitchToIframe] Frame details - ID: ${id}, Class: ${className}, Src: ${src}`);
if ((parsedStep.target &&
(parsedStep.target === id ||
parsedStep.target === className ||
parsedStep.target === src)) ||
(!parsedStep.target && src)) {
this.currentFrame = frame;
this.addLog(`[SwitchToIframe] Switched to frame: ID=${id}, Class=${className}, Src=${src}`);
iframeFound = true;
break;
}
}
}
catch (frameError) {
if (frameError instanceof Error) {
this.addLog(`[SwitchToIframe] Error checking frame: ${frameError.message}`);
}
}
}
if (!iframeFound) {
this.addLog('[SwitchToIframe] Iframe not found, staying on main frame');
}
}
catch (error) {
if (error instanceof Error) {
this.addLog(`[SwitchToIframe] Error while searching for iframe: ${error.message}`);
}
}
}
break;
case 'switchToMainContent':
await actionHandlers.handleSwitchToMainContent(this.page, this.currentFrame);
if (this.page) {
this.currentFrame = this.page;
this.addLog('[SwitchToMainContent] Switched back to main frame');
}
break;
case 'skip':
await actionHandlers.handleSkip(this.currentFrame || this.page, this.addLog, parsedStep.originalStep);
break;
default:
this.addLog(`Unsupported action: ${parsedStep.action}`);
}
}
catch (error) {
if (error instanceof Error) {
this.addLog(`[Action] Error in ${parsedStep.action}: ${error.message.substring(0, 50)}...`);
if (error.message.includes('detached Frame') ||
error.name === 'TargetCloseError') {
this.addLog('[Recovery] Frame detached. Restarting browser...');
if (this.browser) {
await this.browser.close().catch((e) => {
if (e instanceof Error) {
this.addLog(`[Recovery] Error closing: ${e.message.substring(0,