qa-agent 0.1.0__py3-none-any.whl

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.
qa_agent/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ QA Agent - Automated Exploratory Testing Tool
3
+
4
+ A Python/Playwright-based QA agent that performs exploratory testing on web applications,
5
+ testing various input methods and detecting UX issues.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("qa-agent")
12
+ except PackageNotFoundError:
13
+ # Package not installed (e.g. running from source without install)
14
+ __version__ = "0.1.0"
qa_agent/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Allow running the package as a module: python -m qa_agent
2
+
3
+ Usage:
4
+ python -m qa_agent <url> [options] # Run tests via CLI
5
+ python -m qa_agent web [options] # Start the web interface
6
+ """
7
+
8
+ import sys
9
+
10
+
11
+ def main():
12
+ if len(sys.argv) > 1 and sys.argv[1] == "web":
13
+ sys.argv.pop(1) # Remove 'web' so serve_web_cli sees only --host/--port/--debug
14
+ from .web.server import serve_web_cli
15
+ serve_web_cli()
16
+ else:
17
+ from .cli import main as cli_main
18
+ cli_main()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
qa_agent/agent.py ADDED
@@ -0,0 +1,532 @@
1
+ """Core QA Agent implementation."""
2
+
3
+ import os
4
+ import re
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from datetime import datetime
9
+ from urllib.parse import urlparse
10
+
11
+ from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
12
+
13
+ from .config import OutputFormat, TestConfig, TestMode
14
+ from .models import Finding, PageAnalysis, TestPlan, TestSession
15
+ from .reporters import ConsoleReporter, JSONReporter, MarkdownReporter, PDFReporter
16
+ from .testers import (
17
+ AccessibilityTester,
18
+ CustomTester,
19
+ ErrorDetector,
20
+ FormTester,
21
+ KeyboardTester,
22
+ MouseTester,
23
+ WCAGComplianceTester,
24
+ )
25
+
26
+
27
+ def _extract_domain(url: str) -> str:
28
+ """Return a filesystem-safe domain (including subdomain) from a URL.
29
+
30
+ e.g. ``https://www.example.com/path`` → ``www.example.com``
31
+ """
32
+ netloc = urlparse(url).netloc
33
+ # Strip port number if present
34
+ netloc = netloc.split(":")[0]
35
+ # Sanitise: keep alphanumerics, dots, and hyphens; replace anything else
36
+ safe = re.sub(r"[^\w.\-]", "_", netloc)
37
+ return safe or "unknown"
38
+
39
+
40
+ class QAAgent:
41
+ """Main QA Agent that orchestrates exploratory testing."""
42
+
43
+ def __init__(self, config: TestConfig):
44
+ self.config = config
45
+ self.session: TestSession | None = None
46
+ self.browser: Browser | None = None
47
+ self.context: BrowserContext | None = None
48
+ self.page: Page | None = None
49
+ self.error_detector: ErrorDetector | None = None
50
+ self.visited_urls: set[str] = set()
51
+ self.urls_to_visit: list[str] = []
52
+ self.test_plan: TestPlan | None = None
53
+ self.stop_event: threading.Event | None = None # Set by web server to request graceful stop
54
+
55
+ # Generate the session ID here so all output paths can be organized
56
+ # under a session-specific subdirectory before reporters are created.
57
+ self.session_id = str(uuid.uuid4())[:8]
58
+
59
+ # Build the session base: output/{domain}/{session_id}/
60
+ domain = _extract_domain(config.urls[0]) if config.urls else "unknown"
61
+ session_base = os.path.join(config.output_dir, domain, self.session_id)
62
+ config.output_dir = os.path.join(session_base, "qa_reports")
63
+ config.screenshots.output_dir = os.path.join(session_base, "screenshots")
64
+ config.recording.output_dir = os.path.join(session_base, "recordings")
65
+
66
+ # Initialize reporters
67
+ self.reporters = []
68
+ if OutputFormat.CONSOLE in config.output_formats:
69
+ self.reporters.append(ConsoleReporter(config.output_dir))
70
+ if OutputFormat.MARKDOWN in config.output_formats:
71
+ self.reporters.append(MarkdownReporter(config.output_dir))
72
+ if OutputFormat.JSON in config.output_formats:
73
+ self.reporters.append(JSONReporter(config.output_dir))
74
+ if OutputFormat.PDF in config.output_formats:
75
+ self.reporters.append(PDFReporter(config.output_dir))
76
+
77
+ self.console = next(
78
+ (r for r in self.reporters if isinstance(r, ConsoleReporter)),
79
+ ConsoleReporter(config.output_dir)
80
+ )
81
+
82
+ def run(self) -> TestSession:
83
+ """Run the complete QA test session."""
84
+ self.session = TestSession(
85
+ session_id=self.session_id,
86
+ start_time=datetime.now(),
87
+ config_summary={
88
+ "mode": self.config.mode.value,
89
+ "urls": self.config.urls,
90
+ "headless": self.config.headless,
91
+ "max_depth": self.config.max_depth if self.config.mode == TestMode.EXPLORE else None,
92
+ "max_pages": self.config.max_pages if self.config.mode == TestMode.EXPLORE else None,
93
+ }
94
+ )
95
+
96
+ # Generate AI test plan if instructions were provided
97
+ if self.config.instructions:
98
+ self._generate_test_plan()
99
+
100
+ with sync_playwright() as playwright:
101
+ self._setup_browser(playwright)
102
+
103
+ try:
104
+ # Authenticate if needed
105
+ if self.config.auth:
106
+ self._authenticate()
107
+
108
+ # Run tests based on mode
109
+ if self.config.mode == TestMode.FOCUSED:
110
+ self._run_focused_mode()
111
+ else:
112
+ self._run_explore_mode()
113
+
114
+ finally:
115
+ self._cleanup()
116
+
117
+ self.session.end_time = datetime.now()
118
+
119
+ # Generate reports
120
+ self._generate_reports()
121
+
122
+ return self.session
123
+
124
+ def _generate_test_plan(self):
125
+ """Call the AI planner to interpret instructions and build a TestPlan.
126
+
127
+ Results are stored in a filesystem cache (keyed by instructions + URLs)
128
+ and reused on subsequent runs with identical inputs unless
129
+ ``config.use_plan_cache`` is False.
130
+ """
131
+ from .ai_planner import AIPlannerClient
132
+ from .plan_cache import PlanCache
133
+
134
+ cache = PlanCache() if self.config.use_plan_cache else None
135
+ cache_key = PlanCache.make_key(self.config.instructions, self.config.urls) if cache else None
136
+
137
+ # Try cache first
138
+ if cache and cache_key:
139
+ cached = cache.get(cache_key)
140
+ if cached is not None:
141
+ self.console.print_progress("Using cached AI test plan (pass --no-cache to regenerate).")
142
+ self.test_plan = cached
143
+ self._apply_test_plan()
144
+ return
145
+
146
+ self.console.print_progress(
147
+ f"Generating AI test plan using {self.config.ai_model}…"
148
+ )
149
+ try:
150
+ planner = AIPlannerClient(model=self.config.ai_model)
151
+ base_url = self.config.urls[0] if self.config.urls else ""
152
+ self.test_plan = planner.plan(self.config.instructions, base_url)
153
+
154
+ if cache and cache_key:
155
+ cache.set(cache_key, self.test_plan)
156
+
157
+ self._apply_test_plan()
158
+
159
+ except Exception as exc:
160
+ self.console.print_progress(
161
+ f"Warning: AI planning failed ({exc}). Continuing with standard tests only."
162
+ )
163
+ self.test_plan = None
164
+
165
+ def _apply_test_plan(self):
166
+ """Print the test plan summary and enqueue any suggested URLs."""
167
+ self.console.print_progress(f"Test plan: {self.test_plan.summary}")
168
+ if self.test_plan.focus_areas:
169
+ self.console.print_progress(
170
+ "Focus areas: " + ", ".join(self.test_plan.focus_areas)
171
+ )
172
+ self.console.print_progress(
173
+ f"Custom test steps: {len(self.test_plan.custom_steps)}"
174
+ )
175
+
176
+ existing = set(self.config.urls)
177
+ added: list[str] = []
178
+ for url in self.test_plan.suggested_urls:
179
+ if url and url not in existing:
180
+ self.config.urls.append(url)
181
+ existing.add(url)
182
+ added.append(url)
183
+ if added:
184
+ self.console.print_progress(
185
+ "AI suggested additional URL(s) to test: " + ", ".join(added)
186
+ )
187
+
188
+ if self.test_plan.notes:
189
+ self.console.print_progress(f"Notes: {self.test_plan.notes}")
190
+
191
+ def _setup_browser(self, playwright):
192
+ """Set up browser and context."""
193
+ browser_options = {
194
+ "headless": self.config.headless,
195
+ }
196
+
197
+ self.browser = playwright.chromium.launch(**browser_options)
198
+
199
+ context_options = {
200
+ "viewport": {
201
+ "width": self.config.viewport_width,
202
+ "height": self.config.viewport_height,
203
+ },
204
+ }
205
+
206
+ # Set up recording if enabled
207
+ if self.config.recording.enabled:
208
+ os.makedirs(self.config.recording.output_dir, exist_ok=True)
209
+ context_options["record_video_dir"] = self.config.recording.output_dir
210
+ context_options["record_video_size"] = self.config.recording.video_size
211
+
212
+ # Add custom headers if provided in auth
213
+ if self.config.auth and self.config.auth.headers:
214
+ context_options["extra_http_headers"] = self.config.auth.headers
215
+
216
+ self.context = self.browser.new_context(**context_options)
217
+ self.context.set_default_timeout(self.config.timeout)
218
+
219
+ self.page = self.context.new_page()
220
+
221
+ # Set up error detector
222
+ self.error_detector = ErrorDetector(self.page, self.config)
223
+ self.error_detector.attach_listeners()
224
+
225
+ def _authenticate(self):
226
+ """Perform authentication if configured."""
227
+ auth = self.config.auth
228
+
229
+ # Handle cookies
230
+ if auth.cookies:
231
+ self.context.add_cookies([auth.cookies] if isinstance(auth.cookies, dict) else auth.cookies)
232
+ return
233
+
234
+ # Handle form-based auth
235
+ if auth.auth_url and auth.username and auth.password:
236
+ self.console.print_progress(f"Authenticating at {auth.auth_url}")
237
+ self.page.goto(auth.auth_url)
238
+
239
+ # Find and fill username
240
+ username_selector = auth.username_selector or 'input[type="email"], input[type="text"][name*="user"], input[name*="email"], input#username, input#email'
241
+ try:
242
+ self.page.fill(username_selector, auth.username)
243
+ except Exception as e:
244
+ self.console.print_progress(f"Warning: Could not fill username field: {e}")
245
+
246
+ # Find and fill password
247
+ password_selector = auth.password_selector or 'input[type="password"]'
248
+ try:
249
+ self.page.fill(password_selector, auth.password)
250
+ except Exception as e:
251
+ self.console.print_progress(f"Warning: Could not fill password field: {e}")
252
+
253
+ # Submit
254
+ submit_selector = auth.submit_selector or 'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
255
+ try:
256
+ self.page.click(submit_selector)
257
+ self.page.wait_for_load_state("networkidle", timeout=10000)
258
+ except Exception as e:
259
+ self.console.print_progress(f"Warning: Could not submit login form: {e}")
260
+
261
+ def _run_focused_mode(self):
262
+ """Run tests on specific URLs only."""
263
+ for url in self.config.urls:
264
+ if self.stop_event and self.stop_event.is_set():
265
+ break
266
+ if url in self.visited_urls:
267
+ continue
268
+
269
+ self._test_page(url)
270
+ self.visited_urls.add(url)
271
+
272
+ def _run_explore_mode(self):
273
+ """Explore and test pages, following links."""
274
+ # Initialize with seed URLs
275
+ self.urls_to_visit = list(self.config.urls)
276
+ depth_map = {url: 0 for url in self.urls_to_visit}
277
+
278
+ while self.urls_to_visit and len(self.visited_urls) < self.config.max_pages:
279
+ if self.stop_event and self.stop_event.is_set():
280
+ break
281
+ url = self.urls_to_visit.pop(0)
282
+
283
+ if url in self.visited_urls:
284
+ continue
285
+
286
+ current_depth = depth_map.get(url, 0)
287
+
288
+ if current_depth > self.config.max_depth:
289
+ continue
290
+
291
+ self._test_page(url)
292
+ self.visited_urls.add(url)
293
+
294
+ # Discover new links
295
+ if current_depth < self.config.max_depth:
296
+ new_urls = self._discover_links(url)
297
+ for new_url in new_urls:
298
+ if new_url not in self.visited_urls and new_url not in self.urls_to_visit:
299
+ if not self._should_skip_url(new_url):
300
+ self.urls_to_visit.append(new_url)
301
+ depth_map[new_url] = current_depth + 1
302
+
303
+ def _test_page(self, url: str):
304
+ """Test a single page."""
305
+ self.console.print_page_start(url)
306
+
307
+ try:
308
+ start_time = time.time()
309
+ self.page.goto(url, wait_until="domcontentloaded")
310
+ self.page.wait_for_load_state("networkidle", timeout=10000)
311
+ load_time = (time.time() - start_time) * 1000
312
+ except Exception as e:
313
+ self.console.print_progress(f"Error loading page: {e}")
314
+ return
315
+
316
+ # Gather page info
317
+ page_info = self._analyze_page_structure()
318
+
319
+ page_analysis = PageAnalysis(
320
+ url=url,
321
+ title=self.page.title(),
322
+ load_time_ms=load_time,
323
+ interactive_elements=page_info["interactive_elements"],
324
+ forms_count=page_info["forms_count"],
325
+ links_count=page_info["links_count"],
326
+ images_count=page_info["images_count"],
327
+ )
328
+
329
+ # Run testers
330
+ all_findings: list[Finding] = []
331
+
332
+ if self.config.test_keyboard:
333
+ self.console.print_test_category("keyboard navigation")
334
+ tester = KeyboardTester(self.page, self.config)
335
+ findings = tester.run()
336
+ all_findings.extend(findings)
337
+ for f in findings:
338
+ self.console.print_finding(f)
339
+
340
+ if self.config.test_mouse:
341
+ self.console.print_test_category("mouse interaction")
342
+ tester = MouseTester(self.page, self.config)
343
+ findings = tester.run()
344
+ all_findings.extend(findings)
345
+ for f in findings:
346
+ self.console.print_finding(f)
347
+
348
+ if self.config.test_forms:
349
+ self.console.print_test_category("form handling")
350
+ tester = FormTester(self.page, self.config)
351
+ findings = tester.run()
352
+ all_findings.extend(findings)
353
+ for f in findings:
354
+ self.console.print_finding(f)
355
+
356
+ if self.config.test_accessibility:
357
+ self.console.print_test_category("accessibility")
358
+ tester = AccessibilityTester(self.page, self.config)
359
+ findings = tester.run()
360
+ all_findings.extend(findings)
361
+ for f in findings:
362
+ self.console.print_finding(f)
363
+
364
+ if self.config.test_wcag_compliance:
365
+ self.console.print_test_category("WCAG 2.1 AA compliance")
366
+ tester = WCAGComplianceTester(self.page, self.config)
367
+ findings = tester.run()
368
+ all_findings.extend(findings)
369
+ for f in findings:
370
+ self.console.print_finding(f)
371
+
372
+ if self.config.test_console_errors or self.config.test_network_errors:
373
+ self.console.print_test_category("error detection")
374
+ findings = self.error_detector.run()
375
+ all_findings.extend(findings)
376
+ for f in findings:
377
+ self.console.print_finding(f)
378
+
379
+ self.error_detector.get_summary()
380
+ page_analysis.console_errors = [
381
+ m["text"] for m in self.error_detector.console_messages
382
+ if m["type"] == "error"
383
+ ]
384
+ page_analysis.network_errors = self.error_detector.network_errors
385
+
386
+ if self.test_plan and self.test_plan.custom_steps:
387
+ self.console.print_test_category("custom AI steps")
388
+ tester = CustomTester(self.page, self.config, self.test_plan)
389
+ findings = tester.run()
390
+ all_findings.extend(findings)
391
+ for f in findings:
392
+ self.console.print_finding(f)
393
+
394
+ # Take screenshot if there were errors
395
+ if all_findings and self.config.screenshots.on_error:
396
+ screenshot_path = self._take_screenshot(f"page_{len(self.visited_urls)}")
397
+ if screenshot_path:
398
+ for finding in all_findings:
399
+ if not finding.screenshot_path:
400
+ finding.screenshot_path = screenshot_path
401
+
402
+ page_analysis.findings = all_findings
403
+ self.session.add_page_analysis(page_analysis)
404
+
405
+ # Reset error detector for next page
406
+ self.error_detector.console_messages = []
407
+ self.error_detector.network_errors = []
408
+ self.error_detector.js_errors = []
409
+
410
+ def _analyze_page_structure(self) -> dict:
411
+ """Analyze the structure of the current page."""
412
+ try:
413
+ return self.page.evaluate("""() => ({
414
+ interactive_elements: document.querySelectorAll('a, button, input, select, textarea, [onclick], [role="button"]').length,
415
+ forms_count: document.querySelectorAll('form').length,
416
+ links_count: document.querySelectorAll('a[href]').length,
417
+ images_count: document.querySelectorAll('img').length,
418
+ })""")
419
+ except Exception:
420
+ return {
421
+ "interactive_elements": 0,
422
+ "forms_count": 0,
423
+ "links_count": 0,
424
+ "images_count": 0,
425
+ }
426
+
427
+ def _discover_links(self, current_url: str) -> list[str]:
428
+ """Discover links on the current page for exploration."""
429
+ try:
430
+ links = self.page.evaluate("""() => {
431
+ const links = document.querySelectorAll('a[href]');
432
+ return Array.from(links).map(a => a.href).filter(href =>
433
+ href &&
434
+ !href.startsWith('javascript:') &&
435
+ !href.startsWith('mailto:') &&
436
+ !href.startsWith('tel:') &&
437
+ !href.startsWith('#')
438
+ );
439
+ }""")
440
+
441
+ valid_links = []
442
+ current_domain = urlparse(current_url).netloc
443
+
444
+ for link in links:
445
+ parsed = urlparse(link)
446
+
447
+ # Filter by domain if configured
448
+ if self.config.same_domain_only and parsed.netloc != current_domain:
449
+ continue
450
+
451
+ # Normalize URL
452
+ normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
453
+ if normalized.endswith('/'):
454
+ normalized = normalized[:-1]
455
+
456
+ valid_links.append(normalized)
457
+
458
+ return list(set(valid_links))
459
+
460
+ except Exception:
461
+ return []
462
+
463
+ def _should_skip_url(self, url: str) -> bool:
464
+ """Check if URL should be skipped based on ignore patterns."""
465
+ for pattern in self.config.ignore_patterns:
466
+ if re.search(pattern, url):
467
+ return True
468
+
469
+ # Skip common non-page resources
470
+ skip_extensions = ['.pdf', '.zip', '.jpg', '.png', '.gif', '.svg', '.css', '.js', '.ico']
471
+ for ext in skip_extensions:
472
+ if url.lower().endswith(ext):
473
+ return True
474
+
475
+ return False
476
+
477
+ def _take_screenshot(self, name: str) -> str | None:
478
+ """Take a screenshot and return the path."""
479
+ if not self.config.screenshots.enabled:
480
+ return None
481
+
482
+ os.makedirs(self.config.screenshots.output_dir, exist_ok=True)
483
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
484
+ filename = f"{name}_{timestamp}.png"
485
+ filepath = os.path.join(self.config.screenshots.output_dir, filename)
486
+
487
+ try:
488
+ self.page.screenshot(
489
+ path=filepath,
490
+ full_page=self.config.screenshots.full_page
491
+ )
492
+ return filepath
493
+ except Exception:
494
+ return None
495
+
496
+ def _cleanup(self):
497
+ """Clean up browser resources."""
498
+ if self.config.recording.enabled and self.context:
499
+ # Get video path
500
+ try:
501
+ video = self.page.video
502
+ if video:
503
+ video_path = video.path()
504
+ self.session.recording_path = video_path
505
+ except Exception:
506
+ pass
507
+
508
+ if self.context:
509
+ self.context.close()
510
+ if self.browser:
511
+ self.browser.close()
512
+
513
+ def _generate_reports(self):
514
+ """Generate all configured reports."""
515
+ for reporter in self.reporters:
516
+ if isinstance(reporter, ConsoleReporter):
517
+ reporter.generate(self.session)
518
+ elif isinstance(reporter, MarkdownReporter):
519
+ filepath = reporter.generate(self.session)
520
+ self.console.print_progress(f"Markdown report saved: {filepath}")
521
+ elif isinstance(reporter, JSONReporter):
522
+ filepath = reporter.generate(self.session)
523
+ self.console.print_progress(f"JSON report saved: {filepath}")
524
+ elif isinstance(reporter, PDFReporter):
525
+ try:
526
+ filepath = reporter.generate(self.session)
527
+ self.console.print_progress(f"PDF report saved: {filepath}")
528
+ except ImportError as e:
529
+ self.console.print_progress(f"PDF not available ({e}), falling back to Markdown")
530
+ fallback = MarkdownReporter(reporter.output_dir)
531
+ filepath = fallback.generate(self.session)
532
+ self.console.print_progress(f"Markdown report saved: {filepath}")