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 +14 -0
- qa_agent/__main__.py +22 -0
- qa_agent/agent.py +532 -0
- qa_agent/ai_planner.py +302 -0
- qa_agent/cli.py +412 -0
- qa_agent/config.py +98 -0
- qa_agent/models.py +259 -0
- qa_agent/plan_cache.py +132 -0
- qa_agent/reporters/__init__.py +8 -0
- qa_agent/reporters/base.py +43 -0
- qa_agent/reporters/console.py +166 -0
- qa_agent/reporters/json_reporter.py +78 -0
- qa_agent/reporters/markdown.py +226 -0
- qa_agent/reporters/pdf.py +430 -0
- qa_agent/testers/__init__.py +19 -0
- qa_agent/testers/accessibility.py +629 -0
- qa_agent/testers/base.py +70 -0
- qa_agent/testers/custom.py +220 -0
- qa_agent/testers/errors.py +382 -0
- qa_agent/testers/forms.py +505 -0
- qa_agent/testers/keyboard.py +400 -0
- qa_agent/testers/mouse.py +387 -0
- qa_agent/testers/wcag_compliance.py +1165 -0
- qa_agent/web/__init__.py +0 -0
- qa_agent/web/server.py +654 -0
- qa_agent-0.1.0.dist-info/METADATA +468 -0
- qa_agent-0.1.0.dist-info/RECORD +30 -0
- qa_agent-0.1.0.dist-info/WHEEL +5 -0
- qa_agent-0.1.0.dist-info/entry_points.txt +3 -0
- qa_agent-0.1.0.dist-info/top_level.txt +1 -0
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}")
|