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.
Files changed (35) hide show
  1. package/LICENSE.md +209 -0
  2. package/README.md +474 -0
  3. package/bin/endorphin.js +256 -0
  4. package/examples/endorphin.config.js +22 -0
  5. package/examples/tests/QE-001-basic-login-test.js +18 -0
  6. package/examples/tests/sample-test.js +9 -0
  7. package/examples/tools/.gitkeep +0 -0
  8. package/framework/config/agent-config.js +53 -0
  9. package/framework/config/browser-config.js +53 -0
  10. package/framework/config/paths.js +40 -0
  11. package/framework/core/agent-setup.js +75 -0
  12. package/framework/core/browser-framework.js +766 -0
  13. package/framework/core/config-loader.js +309 -0
  14. package/framework/core/test-discovery.js +402 -0
  15. package/framework/core/test-manager.js +343 -0
  16. package/framework/core/test-recorder.js +302 -0
  17. package/framework/core/test-runner.js +133 -0
  18. package/framework/core/test-session.js +98 -0
  19. package/framework/index.js +44 -0
  20. package/framework/interactive/enhanced-interactive-recorder.js +223 -0
  21. package/framework/interactive/index.js +18 -0
  22. package/framework/interactive/interactive-test-clean.js +33 -0
  23. package/framework/interactive/interactive-test.js +33 -0
  24. package/framework/test-framework.js +29 -0
  25. package/framework/testing/index.js +15 -0
  26. package/framework/testing/test-interactive-recorder.js +83 -0
  27. package/framework/testing/test-modular-framework.js +47 -0
  28. package/framework/testing/verify-test-format.js +58 -0
  29. package/framework/tools/content.js +67 -0
  30. package/framework/tools/index.js +52 -0
  31. package/framework/tools/interaction.js +180 -0
  32. package/framework/tools/navigation.js +43 -0
  33. package/framework/tools/utilities.js +99 -0
  34. package/framework/tools/verification.js +84 -0
  35. 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
+ }