endorphin-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +209 -0
- package/README.md +474 -0
- package/bin/endorphin.js +256 -0
- package/examples/endorphin.config.js +22 -0
- package/examples/tests/QE-001-basic-login-test.js +18 -0
- package/examples/tests/sample-test.js +9 -0
- package/examples/tools/.gitkeep +0 -0
- package/framework/config/agent-config.js +53 -0
- package/framework/config/browser-config.js +53 -0
- package/framework/config/paths.js +40 -0
- package/framework/core/agent-setup.js +75 -0
- package/framework/core/browser-framework.js +766 -0
- package/framework/core/config-loader.js +309 -0
- package/framework/core/test-discovery.js +402 -0
- package/framework/core/test-manager.js +343 -0
- package/framework/core/test-recorder.js +302 -0
- package/framework/core/test-runner.js +133 -0
- package/framework/core/test-session.js +98 -0
- package/framework/index.js +44 -0
- package/framework/interactive/enhanced-interactive-recorder.js +223 -0
- package/framework/interactive/index.js +18 -0
- package/framework/interactive/interactive-test-clean.js +33 -0
- package/framework/interactive/interactive-test.js +33 -0
- package/framework/test-framework.js +29 -0
- package/framework/testing/index.js +15 -0
- package/framework/testing/test-interactive-recorder.js +83 -0
- package/framework/testing/test-modular-framework.js +47 -0
- package/framework/testing/verify-test-format.js +58 -0
- package/framework/tools/content.js +67 -0
- package/framework/tools/index.js +52 -0
- package/framework/tools/interaction.js +180 -0
- package/framework/tools/navigation.js +43 -0
- package/framework/tools/utilities.js +99 -0
- package/framework/tools/verification.js +84 -0
- package/package.json +84 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// Endorphin e2e AI test framework>
|
|
2
|
+
// Copyright (C) 2025 Redstudio Agency
|
|
3
|
+
|
|
4
|
+
// This program is free software: you can redistribute it and/or modify
|
|
5
|
+
// it under the terms of the GNU Affero General Public License as
|
|
6
|
+
// published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
// License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
// This program is distributed in the hope that it will be useful,
|
|
10
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
// GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
21
|
+
import { HumanMessage } from "@langchain/core/messages";
|
|
22
|
+
import fs, { existsSync, readdirSync, statSync, rmSync, mkdirSync } from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import { BROWSER_CONFIG } from '../config/browser-config.js';
|
|
25
|
+
import { AGENT_CONFIG } from '../config/agent-config.js';
|
|
26
|
+
import { PATHS } from '../config/paths.js';
|
|
27
|
+
import { createTestSession, saveTestSession } from './test-session.js';
|
|
28
|
+
import { setupAgent } from './agent-setup.js';
|
|
29
|
+
import { createAllTools } from '../tools/index.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Enhanced Browser Test Framework - Core Framework Class
|
|
33
|
+
* Handles browser lifecycle, tool setup, and test session management
|
|
34
|
+
*/
|
|
35
|
+
export class EnhancedBrowserTestFramework {
|
|
36
|
+
constructor(config = {}) {
|
|
37
|
+
this.browser = null;
|
|
38
|
+
this.context = null;
|
|
39
|
+
this.page = null;
|
|
40
|
+
this.tools = null;
|
|
41
|
+
this.agent = null;
|
|
42
|
+
this.testResults = [];
|
|
43
|
+
this.currentTestSession = null;
|
|
44
|
+
// Will be set after config is processed
|
|
45
|
+
this.resultBaseDir = null;
|
|
46
|
+
this.recorderBaseDir = PATHS.TEST_RECORDER_DIR;
|
|
47
|
+
this.isInteractiveMode = false;
|
|
48
|
+
|
|
49
|
+
// Initialize configuration with defaults - deep merge to prevent issues
|
|
50
|
+
const defaultConfig = {
|
|
51
|
+
browser: {
|
|
52
|
+
type: 'chromium',
|
|
53
|
+
headless: true,
|
|
54
|
+
viewport: {
|
|
55
|
+
width: 1280,
|
|
56
|
+
height: 720
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
ai: {
|
|
60
|
+
temperature: 0.1,
|
|
61
|
+
maxTokens: 8000
|
|
62
|
+
},
|
|
63
|
+
execution: {
|
|
64
|
+
timeout: 30000,
|
|
65
|
+
parallel: false,
|
|
66
|
+
retries: 0
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Deep merge: defaults first, then user config for nested objects
|
|
71
|
+
this.config = {
|
|
72
|
+
...defaultConfig,
|
|
73
|
+
...config,
|
|
74
|
+
browser: {
|
|
75
|
+
...defaultConfig.browser,
|
|
76
|
+
...(config.browser || {})
|
|
77
|
+
},
|
|
78
|
+
ai: {
|
|
79
|
+
...defaultConfig.ai,
|
|
80
|
+
...(config.ai || {})
|
|
81
|
+
},
|
|
82
|
+
execution: {
|
|
83
|
+
...defaultConfig.execution,
|
|
84
|
+
...(config.execution || {})
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Set result directory based on configuration
|
|
89
|
+
// Use configured results directory or default to 'test-results' in current working directory
|
|
90
|
+
const resultsDir = this.config.results?.directory || './test-results';
|
|
91
|
+
this.resultBaseDir = path.resolve(process.cwd(), resultsDir);
|
|
92
|
+
|
|
93
|
+
// Debug logging
|
|
94
|
+
console.log(`š§ Results config:`, this.config.results);
|
|
95
|
+
console.log(`š Resolved results directory: ${this.resultBaseDir}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getBrowserType() {
|
|
99
|
+
switch (this.config.browser.type) {
|
|
100
|
+
case 'firefox':
|
|
101
|
+
return firefox;
|
|
102
|
+
case 'webkit':
|
|
103
|
+
return webkit;
|
|
104
|
+
case 'chromium':
|
|
105
|
+
default:
|
|
106
|
+
return chromium;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async initialize() {
|
|
111
|
+
console.log("š Initializing Enhanced Browser Test Framework...");
|
|
112
|
+
|
|
113
|
+
// Clean up directories before starting
|
|
114
|
+
await this.cleanupDirectories();
|
|
115
|
+
|
|
116
|
+
// Ensure directories exist
|
|
117
|
+
if (!fs.existsSync(this.resultBaseDir)) {
|
|
118
|
+
fs.mkdirSync(this.resultBaseDir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
if (!fs.existsSync(this.recorderBaseDir)) {
|
|
121
|
+
fs.mkdirSync(this.recorderBaseDir, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Launch browser using config
|
|
125
|
+
const browserType = this.getBrowserType();
|
|
126
|
+
const launchOptions = {
|
|
127
|
+
headless: this.config.browser.headless,
|
|
128
|
+
args: ['--start-maximized']
|
|
129
|
+
};
|
|
130
|
+
const contextOptions = {
|
|
131
|
+
viewport: this.config.browser.viewport
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
this.browser = await browserType.launch(launchOptions);
|
|
135
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
136
|
+
this.page = await this.context.newPage();
|
|
137
|
+
|
|
138
|
+
// Setup tools with tracking
|
|
139
|
+
this.setupTools();
|
|
140
|
+
|
|
141
|
+
// Create AI agent
|
|
142
|
+
await this.setupAgent();
|
|
143
|
+
|
|
144
|
+
console.log("ā
Framework initialized successfully!");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setupTools() {
|
|
148
|
+
console.log("š ļø Setting up browser automation tools...");
|
|
149
|
+
|
|
150
|
+
// Create tools for the AI agent
|
|
151
|
+
this.toolsArray = createAllTools(this);
|
|
152
|
+
|
|
153
|
+
// Create direct tool access object for framework use
|
|
154
|
+
this.tools = {
|
|
155
|
+
navigate: async (params) => {
|
|
156
|
+
const stepDesc = `Navigate to: ${params.url}`;
|
|
157
|
+
console.log(`š ${stepDesc}`);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await this.page.goto(params.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
161
|
+
await this.takeStepScreenshot(`Page loaded: ${params.url}`);
|
|
162
|
+
|
|
163
|
+
this.logTestStep(stepDesc, 'navigate', params, `Successfully navigated to: ${params.url}`, true);
|
|
164
|
+
return `Successfully navigated to: ${params.url}`;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.logTestStep(stepDesc, 'navigate', params, error.message, false);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
click: async (params) => {
|
|
172
|
+
const stepDesc = `Click element: ${params.selector}`;
|
|
173
|
+
console.log(`š ${stepDesc}`);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await this.page.locator(params.selector).click();
|
|
177
|
+
await this.takeStepScreenshot(`Clicked: ${params.selector}`);
|
|
178
|
+
|
|
179
|
+
this.logTestStep(stepDesc, 'click', params, `Successfully clicked: ${params.selector}`, true);
|
|
180
|
+
return `Successfully clicked: ${params.selector}`;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.logTestStep(stepDesc, 'click', params, error.message, false);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
fill: async (params) => {
|
|
188
|
+
const stepDesc = `Fill field: ${params.selector} with "${params.text}"`;
|
|
189
|
+
console.log(`āļø ${stepDesc}`);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await this.page.locator(params.selector).fill(params.text);
|
|
193
|
+
await this.takeStepScreenshot(`Filled: ${params.selector}`);
|
|
194
|
+
|
|
195
|
+
this.logTestStep(stepDesc, 'fill', params, `Successfully filled: ${params.selector}`, true);
|
|
196
|
+
return `Successfully filled: ${params.selector}`;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.logTestStep(stepDesc, 'fill', params, error.message, false);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
screenshot: async (params = {}) => {
|
|
204
|
+
const filename = params.filename || `screenshot-${Date.now()}.png`;
|
|
205
|
+
await this.takeStepScreenshot(filename);
|
|
206
|
+
return `Screenshot saved: ${filename}`;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async setupAgent() {
|
|
212
|
+
console.log("š¤ Setting up AI agent...");
|
|
213
|
+
this.agent = await setupAgent(this.toolsArray);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Directory cleanup methods
|
|
217
|
+
async cleanupDirectories() {
|
|
218
|
+
// Skip cleanup in test environment
|
|
219
|
+
if (process.env.NODE_ENV === 'test' || process.env.VITEST) {
|
|
220
|
+
console.log("š§¹ Skipping cleanup in test environment");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log("š§¹ Cleaning up previous test results...");
|
|
225
|
+
|
|
226
|
+
if (existsSync(this.resultBaseDir)) {
|
|
227
|
+
const files = readdirSync(this.resultBaseDir);
|
|
228
|
+
for (const file of files) {
|
|
229
|
+
const filePath = path.join(this.resultBaseDir, file);
|
|
230
|
+
if (statSync(filePath).isDirectory()) {
|
|
231
|
+
rmSync(filePath, { recursive: true, force: true });
|
|
232
|
+
} else {
|
|
233
|
+
fs.unlinkSync(filePath);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
console.log(" ā
Cleaned test-result directory");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async cleanupRecorderDirectory() {
|
|
241
|
+
if (fs.existsSync(this.recorderBaseDir)) {
|
|
242
|
+
const files = fs.readdirSync(this.recorderBaseDir);
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
const filePath = path.join(this.recorderBaseDir, file);
|
|
245
|
+
if (fs.statSync(filePath).isDirectory()) {
|
|
246
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
247
|
+
} else {
|
|
248
|
+
fs.unlinkSync(filePath);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
console.log(" ā
Cleaned test-recorder directory (interactive mode)");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Test session management
|
|
256
|
+
createTestSession(testName, testId = null) {
|
|
257
|
+
const session = createTestSession(testName, testId, this.resultBaseDir);
|
|
258
|
+
this.currentTestSession = session;
|
|
259
|
+
return session;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
logTestStep(stepDescription, toolName = null, toolArgs = null, result = null, isSuccess = true) {
|
|
263
|
+
if (!this.currentTestSession) return;
|
|
264
|
+
|
|
265
|
+
this.currentTestSession.stepCounter++;
|
|
266
|
+
const step = {
|
|
267
|
+
stepNumber: this.currentTestSession.stepCounter,
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
description: stepDescription,
|
|
270
|
+
toolName,
|
|
271
|
+
toolArgs,
|
|
272
|
+
result,
|
|
273
|
+
status: isSuccess ? 'SUCCESS' : 'FAILED',
|
|
274
|
+
screenshots: []
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
this.currentTestSession.steps.push(step);
|
|
278
|
+
|
|
279
|
+
if (toolName) {
|
|
280
|
+
this.currentTestSession.toolCalls.push({
|
|
281
|
+
stepNumber: this.currentTestSession.stepCounter,
|
|
282
|
+
toolName,
|
|
283
|
+
toolArgs,
|
|
284
|
+
result,
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
status: isSuccess ? 'SUCCESS' : 'FAILED'
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log(`š Step ${this.currentTestSession.stepCounter}: ${stepDescription} - ${isSuccess ? 'ā
' : 'ā'}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async takeStepScreenshot(description = null) {
|
|
294
|
+
if (!this.currentTestSession) return;
|
|
295
|
+
|
|
296
|
+
this.currentTestSession.screenshotCounter++;
|
|
297
|
+
const filename = `step-${this.currentTestSession.stepCounter || 0}-screenshot-${this.currentTestSession.screenshotCounter}.png`;
|
|
298
|
+
const filepath = path.join(this.currentTestSession.screenshotsDir, filename);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await this.page.screenshot({
|
|
302
|
+
path: filepath,
|
|
303
|
+
fullPage: false,
|
|
304
|
+
type: 'png'
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const screenshot = {
|
|
308
|
+
filename,
|
|
309
|
+
filepath,
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
description: description || `Step ${this.currentTestSession.stepCounter} screenshot`,
|
|
312
|
+
stepNumber: this.currentTestSession.stepCounter
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Add to current step if exists
|
|
316
|
+
if (this.currentTestSession.steps.length > 0) {
|
|
317
|
+
const currentStep = this.currentTestSession.steps[this.currentTestSession.steps.length - 1];
|
|
318
|
+
currentStep.screenshots.push(screenshot);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log(`šø Screenshot taken: ${filename}`);
|
|
322
|
+
return filepath;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
console.error(`ā Failed to take screenshot: ${error.message}`);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async finishTestSession(status = 'SUCCESS', finalResult = null) {
|
|
330
|
+
if (!this.currentTestSession) return;
|
|
331
|
+
|
|
332
|
+
this.currentTestSession.endTime = new Date().toISOString();
|
|
333
|
+
this.currentTestSession.status = status;
|
|
334
|
+
this.currentTestSession.finalResult = finalResult;
|
|
335
|
+
this.currentTestSession.duration = new Date(this.currentTestSession.endTime) - new Date(this.currentTestSession.startTime);
|
|
336
|
+
|
|
337
|
+
// Save session data
|
|
338
|
+
const summary = saveTestSession(this.currentTestSession);
|
|
339
|
+
|
|
340
|
+
this.testResults.push(summary);
|
|
341
|
+
|
|
342
|
+
console.log(`š Test session completed: ${status}`);
|
|
343
|
+
console.log(`š Results saved to: ${this.currentTestSession.sessionDir}`);
|
|
344
|
+
|
|
345
|
+
// Copy all results to test-recorder folder ONLY for interactive modes
|
|
346
|
+
if (this.isInteractiveMode) {
|
|
347
|
+
await this.copyResultsToRecorder(this.currentTestSession);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const session = this.currentTestSession;
|
|
351
|
+
this.currentTestSession = null;
|
|
352
|
+
return summary;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async cleanup() {
|
|
356
|
+
if (this.browser) {
|
|
357
|
+
await this.browser.close();
|
|
358
|
+
this.browser = null;
|
|
359
|
+
this.context = null;
|
|
360
|
+
this.page = null;
|
|
361
|
+
console.log("š§¹ Browser closed successfully");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async runTask(taskDescription, testName = null) {
|
|
366
|
+
const timestamp = new Date().toISOString();
|
|
367
|
+
const name = testName || `Test-${Date.now()}`;
|
|
368
|
+
|
|
369
|
+
console.log(`\nšÆ Running Task: ${name}`);
|
|
370
|
+
console.log(`š Task: ${taskDescription}`);
|
|
371
|
+
console.log(`ā° Started at: ${timestamp}\n`);
|
|
372
|
+
|
|
373
|
+
// Create test session
|
|
374
|
+
const session = this.createTestSession(name, name.replace(/\s+/g, '-').toLowerCase());
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
// Log initial step
|
|
378
|
+
this.logTestStep("Test started", null, null, `Starting task: ${taskDescription}`, true);
|
|
379
|
+
await this.takeStepScreenshot("Initial page state");
|
|
380
|
+
|
|
381
|
+
// Create enhanced context message for the agent
|
|
382
|
+
const systemContext = `You are a browser automation agent with access to the following tools:
|
|
383
|
+
- navigate: Navigate to any URL
|
|
384
|
+
- click: Click elements using various selection strategies (CSS, text, role, etc.)
|
|
385
|
+
- fill: Fill input fields with text
|
|
386
|
+
- wait: Wait for elements or time delays
|
|
387
|
+
- verifyElement: Check if elements exist on the page
|
|
388
|
+
- getPageContent: Get page content for analysis
|
|
389
|
+
- screenshot: Take screenshots
|
|
390
|
+
- getElementInfo: Get information about specific elements
|
|
391
|
+
|
|
392
|
+
IMPORTANT INSTRUCTIONS:
|
|
393
|
+
1. You are currently controlling a browser that may already have a page loaded
|
|
394
|
+
2. When asked to click a button with text like "Log In", use: click with selector="Log In" and strategy="text"
|
|
395
|
+
3. For email fields, use: fill with selector="input[type='email']" or "input[name*='email']"
|
|
396
|
+
4. For password fields, use: fill with selector="input[type='password']" or "input[name*='password']"
|
|
397
|
+
5. Take screenshots between major actions to document the process
|
|
398
|
+
6. If an element is not found with one selector, try alternative selectors
|
|
399
|
+
7. Complete the task step by step without asking for additional information
|
|
400
|
+
8. NEVER ask for URLs or page information - just use the tools directly
|
|
401
|
+
|
|
402
|
+
EXAMPLE:
|
|
403
|
+
To click "Log In" button: {"selector": "Log In", "strategy": "text"}
|
|
404
|
+
To fill email: {"selector": "input[type='email']", "value": "user@example.com"}
|
|
405
|
+
To fill password: {"selector": "input[type='password']", "value": "password123"}
|
|
406
|
+
|
|
407
|
+
Current Task: ${taskDescription}`;
|
|
408
|
+
|
|
409
|
+
const finalState = await this.agent.invoke({
|
|
410
|
+
messages: [new HumanMessage(systemContext)],
|
|
411
|
+
}, {
|
|
412
|
+
recursionLimit: AGENT_CONFIG.agent.recursionLimit,
|
|
413
|
+
configurable: { thread_id: `session-${this.currentTestSession.sessionId}` }
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const result = finalState.messages[finalState.messages.length - 1].content;
|
|
417
|
+
|
|
418
|
+
// Log final step
|
|
419
|
+
this.logTestStep("Test completed", null, null, result, true);
|
|
420
|
+
await this.takeStepScreenshot("Final page state");
|
|
421
|
+
|
|
422
|
+
// Finish session
|
|
423
|
+
await this.finishTestSession('SUCCESS', result);
|
|
424
|
+
|
|
425
|
+
console.log(`\nā
Task "${name}" completed successfully!`);
|
|
426
|
+
console.log(`š Result: ${result}\n`);
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
testName: name,
|
|
430
|
+
task: taskDescription,
|
|
431
|
+
timestamp,
|
|
432
|
+
status: 'SUCCESS',
|
|
433
|
+
result: result,
|
|
434
|
+
duration: Date.now() - new Date(timestamp).getTime(),
|
|
435
|
+
sessionDir: session.sessionDir
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error(`\nā Task "${name}" failed:`);
|
|
440
|
+
console.error(`š„ Error: ${error.message}\n`);
|
|
441
|
+
|
|
442
|
+
// Log error step
|
|
443
|
+
this.logTestStep("Test failed", null, null, error.message, false);
|
|
444
|
+
await this.takeStepScreenshot("Error state");
|
|
445
|
+
|
|
446
|
+
// Finish session with failure
|
|
447
|
+
await this.finishTestSession('FAILED', error.message);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
testName: name,
|
|
451
|
+
task: taskDescription,
|
|
452
|
+
timestamp,
|
|
453
|
+
status: 'FAILED',
|
|
454
|
+
error: error.message,
|
|
455
|
+
duration: Date.now() - new Date(timestamp).getTime(),
|
|
456
|
+
sessionDir: session.sessionDir
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async runMultipleTasks(tasks) {
|
|
462
|
+
console.log(`\nš Running ${tasks.length} tasks sequentially...\n`);
|
|
463
|
+
|
|
464
|
+
const results = [];
|
|
465
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
466
|
+
const task = tasks[i];
|
|
467
|
+
const taskName = task.name || `Task-${i + 1}`;
|
|
468
|
+
|
|
469
|
+
const result = await this.runTask(task.description, taskName);
|
|
470
|
+
results.push(result);
|
|
471
|
+
|
|
472
|
+
// Add delay between tasks
|
|
473
|
+
if (i < tasks.length - 1) {
|
|
474
|
+
console.log("ā±ļø Waiting 2 seconds before next task...\n");
|
|
475
|
+
await new Promise(resolve => setTimeout(resolve, AGENT_CONFIG.execution.stepDelay));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return results;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async runSingleTest(test) {
|
|
483
|
+
console.log(`\nš Starting test: ${test.id} - ${test.name}`);
|
|
484
|
+
console.log(`š Description: ${test.description}`);
|
|
485
|
+
console.log(`šÆ Priority: ${test.priority}`);
|
|
486
|
+
console.log(`š·ļø Tags: ${test.tags ? test.tags.join(', ') : 'None'}`);
|
|
487
|
+
|
|
488
|
+
// Create test session with detailed tracking
|
|
489
|
+
this.createTestSession(test.name, test.id);
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
this.logTestStep(`Starting test execution: ${test.name}`, null, null, `Test ID: ${test.id}`, true);
|
|
493
|
+
|
|
494
|
+
// Check prerequisites if any
|
|
495
|
+
if (test.prerequisites && test.prerequisites.length > 0) {
|
|
496
|
+
this.logTestStep(`Checking prerequisites: ${test.prerequisites.join(', ')}`, null, null, 'Prerequisites validated', true);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Execute the test task with timeout
|
|
500
|
+
const messages = [new HumanMessage(test.task)];
|
|
501
|
+
|
|
502
|
+
// Add timeout to prevent infinite loops
|
|
503
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
504
|
+
setTimeout(() => reject(new Error('Test execution timeout (5 minutes)')), AGENT_CONFIG.agent.timeout);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const agentPromise = this.agent.invoke({ messages }, {
|
|
508
|
+
recursionLimit: AGENT_CONFIG.agent.recursionLimit,
|
|
509
|
+
configurable: { thread_id: `session-${this.currentTestSession.sessionId}` }
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await Promise.race([agentPromise, timeoutPromise]);
|
|
513
|
+
|
|
514
|
+
this.logTestStep('Test execution completed successfully', null, null, 'All steps completed', true);
|
|
515
|
+
|
|
516
|
+
// Finish the test session
|
|
517
|
+
const session = await this.finishTestSession('SUCCESS', 'Test completed successfully');
|
|
518
|
+
|
|
519
|
+
console.log(`ā
Test ${test.id} completed successfully!`);
|
|
520
|
+
return { success: true, session };
|
|
521
|
+
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.error(`ā Test ${test.id} failed:`, error.message);
|
|
524
|
+
|
|
525
|
+
this.logTestStep('Test execution failed', null, null, error.message, false);
|
|
526
|
+
const session = await this.finishTestSession('FAILED', error.message);
|
|
527
|
+
|
|
528
|
+
return { success: false, error: error.message, session };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async runMultipleTests(tests) {
|
|
533
|
+
console.log(`\nšÆ Running ${tests.length} tests with enhanced result tracking...`);
|
|
534
|
+
const results = [];
|
|
535
|
+
|
|
536
|
+
for (const test of tests) {
|
|
537
|
+
const result = await this.runSingleTest(test);
|
|
538
|
+
results.push({
|
|
539
|
+
testId: test.id,
|
|
540
|
+
testName: test.name,
|
|
541
|
+
...result
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Brief pause between tests
|
|
545
|
+
await new Promise(resolve => setTimeout(resolve, AGENT_CONFIG.execution.stepDelay));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Generate final report
|
|
549
|
+
const report = this.generateReport();
|
|
550
|
+
|
|
551
|
+
console.log(`\nš All tests completed!`);
|
|
552
|
+
console.log(`š Final Results: ${results.filter(r => r.success).length}/${results.length} passed`);
|
|
553
|
+
|
|
554
|
+
return { results, report };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async copyResultsToRecorder(session) {
|
|
558
|
+
try {
|
|
559
|
+
const recorderSessionDir = path.join(this.recorderBaseDir, session.sessionName);
|
|
560
|
+
const recorderScreenshotsDir = path.join(recorderSessionDir, 'screenshots');
|
|
561
|
+
|
|
562
|
+
// Create recorder directories
|
|
563
|
+
fs.mkdirSync(recorderSessionDir, { recursive: true });
|
|
564
|
+
fs.mkdirSync(recorderScreenshotsDir, { recursive: true });
|
|
565
|
+
|
|
566
|
+
// Copy all files from session directory
|
|
567
|
+
const sourceFiles = fs.readdirSync(session.sessionDir);
|
|
568
|
+
for (const file of sourceFiles) {
|
|
569
|
+
const sourcePath = path.join(session.sessionDir, file);
|
|
570
|
+
const destPath = path.join(recorderSessionDir, file);
|
|
571
|
+
|
|
572
|
+
if (fs.statSync(sourcePath).isDirectory()) {
|
|
573
|
+
// Copy screenshots directory
|
|
574
|
+
if (file === 'screenshots') {
|
|
575
|
+
const screenshotFiles = fs.readdirSync(sourcePath);
|
|
576
|
+
for (const screenshot of screenshotFiles) {
|
|
577
|
+
const srcScreenshot = path.join(sourcePath, screenshot);
|
|
578
|
+
const destScreenshot = path.join(recorderScreenshotsDir, screenshot);
|
|
579
|
+
fs.copyFileSync(srcScreenshot, destScreenshot);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
// Copy individual files
|
|
584
|
+
fs.copyFileSync(sourcePath, destPath);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
console.log(`š¼ Results recorded in: ${recorderSessionDir}`);
|
|
589
|
+
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error(`ā Error copying results to recorder: ${error.message}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
generateReport() {
|
|
596
|
+
const passed = this.testResults.filter(r => r.status === 'SUCCESS').length;
|
|
597
|
+
const failed = this.testResults.filter(r => r.status === 'FAILED').length;
|
|
598
|
+
const total = this.testResults.length;
|
|
599
|
+
|
|
600
|
+
const report = {
|
|
601
|
+
summary: {
|
|
602
|
+
total,
|
|
603
|
+
passed,
|
|
604
|
+
failed,
|
|
605
|
+
passRate: total > 0 ? (passed / total * 100).toFixed(2) + '%' : '0%',
|
|
606
|
+
generatedAt: new Date().toISOString()
|
|
607
|
+
},
|
|
608
|
+
results: this.testResults
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Save report to main directory and test-result directory
|
|
612
|
+
const timestamp = Date.now();
|
|
613
|
+
const reportPath = `test-report-${timestamp}.json`;
|
|
614
|
+
const resultReportPath = path.join(this.resultBaseDir, `test-report-${timestamp}.json`);
|
|
615
|
+
|
|
616
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
617
|
+
fs.writeFileSync(resultReportPath, JSON.stringify(report, null, 2));
|
|
618
|
+
|
|
619
|
+
console.log(`\nš TEST REPORT:`);
|
|
620
|
+
console.log(`āāāāāāāāāāāāāāā`);
|
|
621
|
+
console.log(`Total Tests: ${total}`);
|
|
622
|
+
console.log(`ā
Passed: ${passed}`);
|
|
623
|
+
console.log(`ā Failed: ${failed}`);
|
|
624
|
+
console.log(`š Pass Rate: ${report.summary.passRate}`);
|
|
625
|
+
console.log(`š Report saved: ${reportPath}`);
|
|
626
|
+
console.log(`š Detailed results in: ${this.resultBaseDir}\n`);
|
|
627
|
+
|
|
628
|
+
return report;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async enableInteractiveMode() {
|
|
632
|
+
this.isInteractiveMode = true;
|
|
633
|
+
console.log("š¼ Interactive mode enabled - results will be recorded in test-recorder folder");
|
|
634
|
+
await this.cleanupRecorderDirectory();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
disableInteractiveMode() {
|
|
638
|
+
this.isInteractiveMode = false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Natural Language Command Execution Methods
|
|
642
|
+
async executeNaturalLanguageCommand(command) {
|
|
643
|
+
const lowerCommand = command.toLowerCase();
|
|
644
|
+
|
|
645
|
+
// Parse common commands
|
|
646
|
+
if (lowerCommand.includes('click')) {
|
|
647
|
+
// Extract selector or button text
|
|
648
|
+
const selector = this.extractSelectorFromCommand(command);
|
|
649
|
+
const result = await this.tools.click({ selector });
|
|
650
|
+
return { toolUsed: 'click', params: { selector }, result };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (lowerCommand.includes('fill') || lowerCommand.includes('type')) {
|
|
654
|
+
// Extract field and value
|
|
655
|
+
const { selector, value } = this.extractFillFromCommand(command);
|
|
656
|
+
const result = await this.tools.fill({ selector, value });
|
|
657
|
+
return { toolUsed: 'fill', params: { selector, value }, result };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (lowerCommand.includes('navigate') || lowerCommand.includes('go to')) {
|
|
661
|
+
// Extract URL
|
|
662
|
+
const url = this.extractUrlFromCommand(command);
|
|
663
|
+
const result = await this.tools.navigate({ url });
|
|
664
|
+
return { toolUsed: 'navigate', params: { url }, result };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (lowerCommand.includes('wait')) {
|
|
668
|
+
// Extract time
|
|
669
|
+
const time = this.extractTimeFromCommand(command) || 2000;
|
|
670
|
+
await this.page.waitForTimeout(time);
|
|
671
|
+
return { toolUsed: 'wait', params: { time }, result: `Waited ${time}ms` };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (lowerCommand.includes('screenshot')) {
|
|
675
|
+
const result = await this.tools.screenshot({});
|
|
676
|
+
return { toolUsed: 'screenshot', params: {}, result };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Default: try to use AI agent to interpret the command
|
|
680
|
+
try {
|
|
681
|
+
const result = await this.runTask(command);
|
|
682
|
+
return { toolUsed: 'ai-agent', params: { command }, result };
|
|
683
|
+
} catch (error) {
|
|
684
|
+
throw new Error(`Could not interpret command: "${command}". ${error.message}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Helper methods for parsing commands
|
|
689
|
+
extractSelectorFromCommand(command) {
|
|
690
|
+
// Try to extract button text, link text, or selector
|
|
691
|
+
const buttonMatch = command.match(/(?:click|press)\s+(?:on\s+)?(?:the\s+)?(.+?)(?:\s+button|\s+link|$)/i);
|
|
692
|
+
if (buttonMatch) {
|
|
693
|
+
const text = buttonMatch[1].trim();
|
|
694
|
+
// Return as text selector for buttons/links
|
|
695
|
+
return text;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Try to extract by common UI element names
|
|
699
|
+
if (command.includes('login')) return 'login';
|
|
700
|
+
if (command.includes('submit')) return 'submit';
|
|
701
|
+
if (command.includes('sign up')) return 'sign up';
|
|
702
|
+
if (command.includes('register')) return 'register';
|
|
703
|
+
|
|
704
|
+
// Default to a generic button selector
|
|
705
|
+
return 'button';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
extractFillFromCommand(command) {
|
|
709
|
+
// Try to extract field and value
|
|
710
|
+
const fillMatch = command.match(/(?:fill|type|enter)\s+(.+?)\s+(?:with|as)\s+(.+)/i);
|
|
711
|
+
if (fillMatch) {
|
|
712
|
+
return {
|
|
713
|
+
selector: fillMatch[1].trim(),
|
|
714
|
+
value: fillMatch[2].trim()
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Try common field patterns
|
|
719
|
+
if (command.includes('email')) {
|
|
720
|
+
const emailMatch = command.match(/email\s+(?:with\s+)?(.+)/i);
|
|
721
|
+
return {
|
|
722
|
+
selector: 'input[type="email"], input[name*="email"], #email',
|
|
723
|
+
value: emailMatch ? emailMatch[1].trim() : ''
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (command.includes('password')) {
|
|
728
|
+
const passMatch = command.match(/password\s+(?:with\s+)?(.+)/i);
|
|
729
|
+
return {
|
|
730
|
+
selector: 'input[type="password"], input[name*="password"], #password',
|
|
731
|
+
value: passMatch ? passMatch[1].trim() : ''
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { selector: 'input', value: '' };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
extractUrlFromCommand(command) {
|
|
739
|
+
const urlMatch = command.match(/(https?:\/\/[^\s]+)/i);
|
|
740
|
+
if (urlMatch) return urlMatch[1];
|
|
741
|
+
|
|
742
|
+
// Default to base URL if no URL found
|
|
743
|
+
return process.env.BASE_URL || 'https://qafromla.herokuapp.com/';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
extractTimeFromCommand(command) {
|
|
747
|
+
const timeMatch = command.match(/(\d+)\s*(?:ms|milliseconds?|seconds?|s)/i);
|
|
748
|
+
if (timeMatch) {
|
|
749
|
+
const num = parseInt(timeMatch[1]);
|
|
750
|
+
const unit = timeMatch[0].toLowerCase();
|
|
751
|
+
if (unit.includes('s') && !unit.includes('ms')) {
|
|
752
|
+
return num * 1000; // Convert seconds to milliseconds
|
|
753
|
+
}
|
|
754
|
+
return num;
|
|
755
|
+
}
|
|
756
|
+
return 2000; // Default 2 seconds
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Set interactive mode for the framework
|
|
761
|
+
* @param {boolean} interactive - Whether to enable interactive mode
|
|
762
|
+
*/
|
|
763
|
+
setInteractiveMode(interactive) {
|
|
764
|
+
this.isInteractiveMode = interactive;
|
|
765
|
+
}
|
|
766
|
+
}
|