qa-agent 0.2.2__tar.gz → 0.2.3__tar.gz
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-0.2.2/qa_agent.egg-info → qa_agent-0.2.3}/PKG-INFO +3 -2
- {qa_agent-0.2.2 → qa_agent-0.2.3}/README.md +2 -1
- {qa_agent-0.2.2 → qa_agent-0.2.3}/pyproject.toml +1 -1
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/__init__.py +1 -1
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/agent.py +85 -29
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/cli.py +3 -3
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/config.py +1 -1
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/console.py +4 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/keyboard.py +8 -4
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/server.py +1 -1
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/static/app.js +25 -0
- qa_agent-0.2.3/qa_agent/web/static/index.js +91 -0
- qa_agent-0.2.3/qa_agent/web/static/run.js +200 -0
- qa_agent-0.2.3/qa_agent/web/static/session.js +21 -0
- qa_agent-0.2.2/qa_agent/web/templates/sessions.html → qa_agent-0.2.3/qa_agent/web/static/sessions.js +0 -25
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/index.html +7 -94
- qa_agent-0.2.3/qa_agent/web/templates/run.html +67 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/session.html +2 -21
- qa_agent-0.2.3/qa_agent/web/templates/sessions.html +22 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3/qa_agent.egg-info}/PKG-INFO +3 -2
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/SOURCES.txt +4 -0
- qa_agent-0.2.2/qa_agent/web/templates/run.html +0 -246
- {qa_agent-0.2.2 → qa_agent-0.2.3}/LICENSE +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/__main__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/ai_planner.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/llm_client.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/models.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/plan_cache.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/base.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/json_reporter.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/markdown.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/pdf.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/accessibility.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/base.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/custom.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/errors.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/forms.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/mouse.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/wcag_compliance.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/static/style.css +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/base.html +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/dependency_links.txt +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/entry_points.txt +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/requires.txt +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/top_level.txt +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/setup.cfg +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/_cli_exit_helper.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/conftest.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/test_smoke.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/test_target_coverage.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_agent.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_ai_planner.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_cli.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_config.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_llm_client.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_models.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_packaging.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_plan_cache.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_plan_warnings.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_reporters.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_accessibility.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_base.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_custom.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_errors.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_forms.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_keyboard.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_mouse.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/web/__init__.py +0 -0
- {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/web/test_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qa-agent
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Agentic exploratory QA testing for web applications
|
|
5
5
|
Author: Bill Richards
|
|
6
6
|
License: MIT License
|
|
@@ -90,7 +90,8 @@ Need targeted tests? Pass plain-English instructions and an LLM generates custom
|
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
<details>
|
|
94
|
+
<summary><strong>Table of Contents</strong></summary>
|
|
94
95
|
|
|
95
96
|
- [Features](#features)
|
|
96
97
|
- [Installation](#installation)
|
|
@@ -54,6 +54,13 @@ class QAAgent:
|
|
|
54
54
|
self.urls_to_visit: list[str] = []
|
|
55
55
|
self.test_plan: TestPlan | None = None
|
|
56
56
|
self.stop_event: threading.Event | None = None # Set by web server to request graceful stop
|
|
57
|
+
self._reauth_count: int = 0 # Guards against infinite re-auth loops on bad credentials
|
|
58
|
+
|
|
59
|
+
# If instructions explicitly mention logout/destructive flows, honour the user's intent
|
|
60
|
+
# and disable the default destructive-link heuristic in explore mode.
|
|
61
|
+
_instruction_text = (config.instructions or "").lower()
|
|
62
|
+
_destructive_keywords = ["logout", "log out", "sign out", "signout", "log-out", "sign-out"]
|
|
63
|
+
self._allow_destructive_urls: bool = any(kw in _instruction_text for kw in _destructive_keywords)
|
|
57
64
|
|
|
58
65
|
# Optional factory callable that returns a sync_playwright() context manager.
|
|
59
66
|
# Used by tests to inject a mock playwright without touching the network.
|
|
@@ -264,8 +271,15 @@ class QAAgent:
|
|
|
264
271
|
ctx = self.config.invocation_context
|
|
265
272
|
if ctx == "cli":
|
|
266
273
|
_selector_hint = (
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
'Tip: use --auth-file with a JSON file, e.g.:\n'
|
|
275
|
+
' {\n'
|
|
276
|
+
' "auth_url": "https://example.com/login",\n'
|
|
277
|
+
' "username": "user@example.com",\n'
|
|
278
|
+
' "password": "yourpassword",\n'
|
|
279
|
+
' "username_selector": "input#email",\n'
|
|
280
|
+
' "password_selector": "input#password",\n'
|
|
281
|
+
' "submit_selector": "button[type=\'submit\']"\n'
|
|
282
|
+
' }'
|
|
269
283
|
)
|
|
270
284
|
elif ctx == "web":
|
|
271
285
|
_selector_hint = (
|
|
@@ -283,20 +297,20 @@ class QAAgent:
|
|
|
283
297
|
try:
|
|
284
298
|
self.page.fill(username_selector, auth.username)
|
|
285
299
|
except Exception as e:
|
|
286
|
-
msg = f"
|
|
300
|
+
msg = f"Could not fill username field: {e}"
|
|
287
301
|
if isinstance(e, PlaywrightTimeoutError) and not auth.username_selector:
|
|
288
302
|
msg += f"\n {_selector_hint}"
|
|
289
|
-
self.console.
|
|
303
|
+
self.console.print_error(msg)
|
|
290
304
|
|
|
291
305
|
# Find and fill password
|
|
292
306
|
password_selector = auth.password_selector or 'input[type="password"]'
|
|
293
307
|
try:
|
|
294
308
|
self.page.fill(password_selector, auth.password)
|
|
295
309
|
except Exception as e:
|
|
296
|
-
msg = f"
|
|
310
|
+
msg = f"Could not fill password field: {e}"
|
|
297
311
|
if isinstance(e, PlaywrightTimeoutError) and not auth.password_selector:
|
|
298
312
|
msg += f"\n {_selector_hint}"
|
|
299
|
-
self.console.
|
|
313
|
+
self.console.print_error(msg)
|
|
300
314
|
|
|
301
315
|
# Submit
|
|
302
316
|
submit_selector = auth.submit_selector or 'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
|
|
@@ -304,10 +318,10 @@ class QAAgent:
|
|
|
304
318
|
self.page.click(submit_selector)
|
|
305
319
|
self.page.wait_for_load_state("networkidle", timeout=10000)
|
|
306
320
|
except Exception as e:
|
|
307
|
-
msg = f"
|
|
321
|
+
msg = f"Could not submit login form: {e}"
|
|
308
322
|
if isinstance(e, PlaywrightTimeoutError) and not auth.submit_selector:
|
|
309
323
|
msg += f"\n {_selector_hint}"
|
|
310
|
-
self.console.
|
|
324
|
+
self.console.print_error(msg)
|
|
311
325
|
|
|
312
326
|
def _run_focused_mode(self):
|
|
313
327
|
"""Run tests on specific URLs only."""
|
|
@@ -344,10 +358,11 @@ class QAAgent:
|
|
|
344
358
|
|
|
345
359
|
# Discover new links
|
|
346
360
|
if current_depth < self.config.max_depth:
|
|
347
|
-
|
|
348
|
-
for
|
|
361
|
+
new_links = self._discover_links(url)
|
|
362
|
+
for link in new_links:
|
|
363
|
+
new_url = link['href']
|
|
349
364
|
if new_url not in self.visited_urls and new_url not in self.urls_to_visit:
|
|
350
|
-
if not self._should_skip_url(new_url):
|
|
365
|
+
if not self._should_skip_url(new_url, link.get('text', '')):
|
|
351
366
|
self.urls_to_visit.append(new_url)
|
|
352
367
|
depth_map[new_url] = current_depth + 1
|
|
353
368
|
|
|
@@ -364,9 +379,27 @@ class QAAgent:
|
|
|
364
379
|
self.page.wait_for_load_state("networkidle", timeout=10000)
|
|
365
380
|
load_time = (time.time() - start_time) * 1000
|
|
366
381
|
except Exception as e:
|
|
367
|
-
self.console.
|
|
382
|
+
self.console.print_error(f"Error loading page: {e}")
|
|
368
383
|
return
|
|
369
384
|
|
|
385
|
+
# If we were redirected to the login page, re-authenticate once and continue.
|
|
386
|
+
# Guard against infinite loops when credentials are wrong.
|
|
387
|
+
auth = self.config.auth
|
|
388
|
+
if auth and auth.auth_url and self.page.url != url:
|
|
389
|
+
auth_path = urlparse(auth.auth_url).path.rstrip('/')
|
|
390
|
+
current_path = urlparse(self.page.url).path.rstrip('/')
|
|
391
|
+
if auth_path and current_path == auth_path:
|
|
392
|
+
if self._reauth_count < 1:
|
|
393
|
+
self._reauth_count += 1
|
|
394
|
+
self.console.print_progress("Detected redirect to login page, re-authenticating...")
|
|
395
|
+
self._authenticate()
|
|
396
|
+
else:
|
|
397
|
+
self.console.print_error(
|
|
398
|
+
"Re-authentication attempted but still redirected to login page — "
|
|
399
|
+
"check credentials. Skipping further re-auth attempts."
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
|
|
370
403
|
# Fail fast on page-level HTTP errors — report one finding, skip all testers
|
|
371
404
|
if response is not None and response.status >= 400:
|
|
372
405
|
status = response.status
|
|
@@ -512,45 +545,57 @@ class QAAgent:
|
|
|
512
545
|
"images_count": 0,
|
|
513
546
|
}
|
|
514
547
|
|
|
515
|
-
|
|
548
|
+
_DESTRUCTIVE_URL_PATTERNS = [
|
|
549
|
+
r'/logout', r'/log-out', r'/sign-out', r'/signout',
|
|
550
|
+
r'/delete-account', r'/deactivate',
|
|
551
|
+
]
|
|
552
|
+
_DESTRUCTIVE_LINK_TEXT = [
|
|
553
|
+
'log out', 'logout', 'sign out', 'signout', 'delete account', 'deactivate account',
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
def _discover_links(self, current_url: str) -> list[dict]:
|
|
516
557
|
"""Discover links on the current page for exploration."""
|
|
517
558
|
assert self.page is not None
|
|
518
559
|
try:
|
|
519
|
-
|
|
560
|
+
raw = self.page.evaluate("""() => {
|
|
520
561
|
const links = document.querySelectorAll('a[href]');
|
|
521
|
-
return Array.from(links).map(a =>
|
|
522
|
-
href
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
!href.startsWith('
|
|
562
|
+
return Array.from(links).map(a => ({
|
|
563
|
+
href: a.href,
|
|
564
|
+
text: (a.textContent || '').trim().toLowerCase()
|
|
565
|
+
})).filter(item =>
|
|
566
|
+
item.href &&
|
|
567
|
+
!item.href.startsWith('javascript:') &&
|
|
568
|
+
!item.href.startsWith('mailto:') &&
|
|
569
|
+
!item.href.startsWith('tel:') &&
|
|
570
|
+
!item.href.startsWith('#')
|
|
527
571
|
);
|
|
528
572
|
}""")
|
|
529
573
|
|
|
530
|
-
|
|
574
|
+
seen: set[str] = set()
|
|
575
|
+
valid_links: list[dict] = []
|
|
531
576
|
current_domain = urlparse(current_url).netloc
|
|
532
577
|
|
|
533
|
-
for
|
|
534
|
-
parsed = urlparse(
|
|
578
|
+
for item in raw:
|
|
579
|
+
parsed = urlparse(item['href'])
|
|
535
580
|
|
|
536
|
-
# Filter by domain if configured
|
|
537
581
|
if self.config.same_domain_only and parsed.netloc != current_domain:
|
|
538
582
|
continue
|
|
539
583
|
|
|
540
|
-
# Normalize URL
|
|
541
584
|
normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
542
585
|
if normalized.endswith('/'):
|
|
543
586
|
normalized = normalized[:-1]
|
|
544
587
|
|
|
545
|
-
|
|
588
|
+
if normalized not in seen:
|
|
589
|
+
seen.add(normalized)
|
|
590
|
+
valid_links.append({'href': normalized, 'text': item['text']})
|
|
546
591
|
|
|
547
|
-
return
|
|
592
|
+
return valid_links
|
|
548
593
|
|
|
549
594
|
except Exception:
|
|
550
595
|
return []
|
|
551
596
|
|
|
552
|
-
def _should_skip_url(self, url: str) -> bool:
|
|
553
|
-
"""Check if URL should be skipped based on ignore patterns."""
|
|
597
|
+
def _should_skip_url(self, url: str, link_text: str = "") -> bool:
|
|
598
|
+
"""Check if URL should be skipped based on ignore patterns or destructive heuristics."""
|
|
554
599
|
for pattern in self.config.ignore_patterns:
|
|
555
600
|
if re.search(pattern, url):
|
|
556
601
|
return True
|
|
@@ -561,6 +606,17 @@ class QAAgent:
|
|
|
561
606
|
if url.lower().endswith(ext):
|
|
562
607
|
return True
|
|
563
608
|
|
|
609
|
+
# Skip destructive/logout URLs — bypassed when AI instructions explicitly request it
|
|
610
|
+
if not self._allow_destructive_urls:
|
|
611
|
+
for pattern in self._DESTRUCTIVE_URL_PATTERNS:
|
|
612
|
+
if re.search(pattern, url, re.IGNORECASE):
|
|
613
|
+
self.console.print_progress(f"Skipping destructive link: {url}")
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
if link_text and any(t in link_text for t in self._DESTRUCTIVE_LINK_TEXT):
|
|
617
|
+
self.console.print_progress(f"Skipping logout link (text match): {url}")
|
|
618
|
+
return True
|
|
619
|
+
|
|
564
620
|
return False
|
|
565
621
|
|
|
566
622
|
def _take_screenshot(self, name: str) -> str | None:
|
|
@@ -141,9 +141,9 @@ Examples:
|
|
|
141
141
|
)
|
|
142
142
|
parser.add_argument(
|
|
143
143
|
"--output-dir",
|
|
144
|
-
default=str(Path.cwd() / "output"),
|
|
145
|
-
help="Base directory for all output (default: ./output relative to current directory). "
|
|
146
|
-
"Results are written to output/{domain}/{session_id}/qa_reports|screenshots|recordings",
|
|
144
|
+
default=str(Path.cwd() / "qa-agent-output"),
|
|
145
|
+
help="Base directory for all output (default: ./qa-agent-output relative to current directory). "
|
|
146
|
+
"Results are written to qa-agent-output/{domain}/{session_id}/qa_reports|screenshots|recordings",
|
|
147
147
|
)
|
|
148
148
|
|
|
149
149
|
# Browser options
|
|
@@ -60,7 +60,7 @@ class TestConfig:
|
|
|
60
60
|
output_formats: list[OutputFormat] = field(
|
|
61
61
|
default_factory=lambda: [OutputFormat.CONSOLE, OutputFormat.MARKDOWN]
|
|
62
62
|
)
|
|
63
|
-
output_dir: str = "./output"
|
|
63
|
+
output_dir: str = "./qa-agent-output"
|
|
64
64
|
|
|
65
65
|
# Browser settings
|
|
66
66
|
headless: bool = True
|
|
@@ -56,6 +56,10 @@ class ConsoleReporter(BaseReporter):
|
|
|
56
56
|
"""Print progress message."""
|
|
57
57
|
print(f" → {message}")
|
|
58
58
|
|
|
59
|
+
def print_error(self, message: str) -> None:
|
|
60
|
+
"""Print an error message in red."""
|
|
61
|
+
print(f" {self._color('✗ ERROR:', '91')} {message}")
|
|
62
|
+
|
|
59
63
|
def print_warning(self, message: str) -> None:
|
|
60
64
|
"""Print a test reliability warning."""
|
|
61
65
|
print(f" ⚠ {self._color('WARNING:', '93')} {message}")
|
|
@@ -327,15 +327,19 @@ class KeyboardTester(BaseTester):
|
|
|
327
327
|
|
|
328
328
|
if focused_index in visited_indices and len(visited_indices) < 3:
|
|
329
329
|
# Focus is cycling through fewer than 3 elements while the page
|
|
330
|
-
# has more than 3 focusable elements — genuine trap.
|
|
330
|
+
# has more than 3 focusable elements — genuine trap per WCAG 2.1 SC 2.1.2.
|
|
331
|
+
# Reported as MEDIUM: even a single-element trap prevents navigation away.
|
|
331
332
|
self.findings.append(Finding(
|
|
332
333
|
title="Potential keyboard trap detected",
|
|
333
|
-
description=
|
|
334
|
+
description=(
|
|
335
|
+
f"Focus cycles through only {len(visited_indices)} element(s) repeatedly. "
|
|
336
|
+
"Keyboard-only users cannot navigate past this component (WCAG 2.1 SC 2.1.2)."
|
|
337
|
+
),
|
|
334
338
|
category=FindingCategory.KEYBOARD_NAVIGATION,
|
|
335
|
-
severity=Severity.
|
|
339
|
+
severity=Severity.MEDIUM,
|
|
336
340
|
url=self.page.url,
|
|
337
341
|
expected_behavior="User should be able to TAB through all interactive elements",
|
|
338
|
-
actual_behavior=f"Focus trapped cycling through {len(visited_indices)}
|
|
342
|
+
actual_behavior=f"Focus trapped cycling through {len(visited_indices)} element(s)",
|
|
339
343
|
metadata={"trapped_elements": list(visited_indices)},
|
|
340
344
|
))
|
|
341
345
|
break
|
|
@@ -28,7 +28,7 @@ from ..llm_client import LLMProvider
|
|
|
28
28
|
|
|
29
29
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
30
30
|
_HERE = Path(__file__).resolve().parent # qa_agent/web/
|
|
31
|
-
OUTPUT_DIR = Path.cwd() / "output"
|
|
31
|
+
OUTPUT_DIR = Path.cwd() / "qa-agent-output"
|
|
32
32
|
|
|
33
33
|
# ── Flask app ──────────────────────────────────────────────────────────────────
|
|
34
34
|
app = Flask(__name__, template_folder=str(_HERE / "templates"))
|
|
@@ -71,6 +71,31 @@
|
|
|
71
71
|
form.reset();
|
|
72
72
|
document.getElementById('explore-section').style.display = 'none';
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
document.getElementById('export-config-btn')?.addEventListener('click', () => {
|
|
76
|
+
const cfg = collectFormData(form);
|
|
77
|
+
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' });
|
|
78
|
+
const a = document.createElement('a');
|
|
79
|
+
a.href = URL.createObjectURL(blob);
|
|
80
|
+
a.download = 'qa-agent-config.json';
|
|
81
|
+
a.click();
|
|
82
|
+
URL.revokeObjectURL(a.href);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
document.getElementById('import-config-input')?.addEventListener('change', function () {
|
|
86
|
+
const file = this.files[0];
|
|
87
|
+
if (!file) return;
|
|
88
|
+
const reader = new FileReader();
|
|
89
|
+
reader.onload = e => {
|
|
90
|
+
try {
|
|
91
|
+
applyConfig(JSON.parse(e.target.result));
|
|
92
|
+
} catch (_) {
|
|
93
|
+
alert('Invalid config file.');
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
reader.readAsText(file);
|
|
97
|
+
this.value = '';
|
|
98
|
+
});
|
|
74
99
|
})();
|
|
75
100
|
|
|
76
101
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Show/hide explore section based on mode
|
|
2
|
+
document.querySelectorAll('input[name="mode"]').forEach(radio => {
|
|
3
|
+
radio.addEventListener('change', () => {
|
|
4
|
+
document.getElementById('explore-section').style.display =
|
|
5
|
+
document.querySelector('input[name="mode"]:checked').value === 'explore' ? '' : 'none';
|
|
6
|
+
});
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Screenshot dependency logic (mirrors CLI coupling):
|
|
10
|
+
// on_error and full_page require screenshots_enabled
|
|
11
|
+
// on_interaction requires on_error (matches --screenshots-all behaviour)
|
|
12
|
+
(function () {
|
|
13
|
+
const ssEnabled = document.getElementById('ss_enabled');
|
|
14
|
+
const ssOnError = document.getElementById('ss_on_error');
|
|
15
|
+
const ssOnInteract = document.getElementById('ss_on_interaction');
|
|
16
|
+
const ssFullPage = document.getElementById('ss_full_page');
|
|
17
|
+
|
|
18
|
+
function syncScreenshots() {
|
|
19
|
+
const enabled = ssEnabled.checked;
|
|
20
|
+
const onError = ssOnError.checked;
|
|
21
|
+
|
|
22
|
+
ssOnError.disabled = !enabled;
|
|
23
|
+
ssFullPage.disabled = !enabled;
|
|
24
|
+
ssOnInteract.disabled = !enabled || !onError;
|
|
25
|
+
|
|
26
|
+
if (!enabled) {
|
|
27
|
+
ssOnError.checked = false;
|
|
28
|
+
ssFullPage.checked = false;
|
|
29
|
+
ssOnInteract.checked = false;
|
|
30
|
+
}
|
|
31
|
+
if (!onError) {
|
|
32
|
+
ssOnInteract.checked = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ssEnabled.addEventListener('change', syncScreenshots);
|
|
37
|
+
ssOnError.addEventListener('change', syncScreenshots);
|
|
38
|
+
syncScreenshots();
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
// LLM provider → model options + env-var hint
|
|
42
|
+
(function () {
|
|
43
|
+
const MODELS = {
|
|
44
|
+
anthropic: [
|
|
45
|
+
{ value: '', label: 'Default (claude-sonnet-4-6)' },
|
|
46
|
+
{ value: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
|
|
47
|
+
{ value: 'claude-opus-4-6', label: 'claude-opus-4-6' },
|
|
48
|
+
{ value: 'claude-haiku-4-5-20251001', label: 'claude-haiku-4-5-20251001' },
|
|
49
|
+
],
|
|
50
|
+
openai: [
|
|
51
|
+
{ value: '', label: 'Default (gpt-4o)' },
|
|
52
|
+
{ value: 'gpt-4o', label: 'gpt-4o' },
|
|
53
|
+
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini' },
|
|
54
|
+
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo' },
|
|
55
|
+
{ value: 'o1', label: 'o1' },
|
|
56
|
+
{ value: 'o1-mini', label: 'o1-mini' },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const KEY_HINTS = {
|
|
60
|
+
anthropic: 'Requires <code>ANTHROPIC_API_KEY</code>',
|
|
61
|
+
openai: 'Requires <code>OPENAI_API_KEY</code>',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function updateLLMOptions() {
|
|
65
|
+
const provider = document.getElementById('llm_provider').value;
|
|
66
|
+
const modelSel = document.getElementById('ai_model');
|
|
67
|
+
const hint = document.getElementById('llm_key_hint');
|
|
68
|
+
const models = MODELS[provider] || [];
|
|
69
|
+
|
|
70
|
+
modelSel.innerHTML = models
|
|
71
|
+
.map(m => `<option value="${m.value}">${m.label}</option>`)
|
|
72
|
+
.join('');
|
|
73
|
+
|
|
74
|
+
if (hint) hint.innerHTML = KEY_HINTS[provider] || '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
document.getElementById('llm_provider').addEventListener('change', updateLLMOptions);
|
|
78
|
+
updateLLMOptions();
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
// Load instructions from a local text/markdown file into the textarea
|
|
82
|
+
document.getElementById('instructions_file')?.addEventListener('change', function () {
|
|
83
|
+
const file = this.files[0];
|
|
84
|
+
if (!file) return;
|
|
85
|
+
const reader = new FileReader();
|
|
86
|
+
reader.onload = e => {
|
|
87
|
+
document.getElementById('instructions').value = e.target.result;
|
|
88
|
+
};
|
|
89
|
+
reader.readAsText(file);
|
|
90
|
+
this.value = '';
|
|
91
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const JOB_ID = document.getElementById('run-header').dataset.jobId;
|
|
3
|
+
const log = document.getElementById('log');
|
|
4
|
+
const statusBadge = document.getElementById('status-badge');
|
|
5
|
+
const stopBtn = document.getElementById('stop-btn');
|
|
6
|
+
const statsEl = document.getElementById('run-stats');
|
|
7
|
+
const statPages = document.getElementById('stat-pages');
|
|
8
|
+
const statFindings = document.getElementById('stat-findings');
|
|
9
|
+
const statUrl = document.getElementById('stat-url');
|
|
10
|
+
const completeBanner = document.getElementById('complete-banner');
|
|
11
|
+
const errorBanner = document.getElementById('error-banner');
|
|
12
|
+
const stoppedBanner = document.getElementById('stopped-banner');
|
|
13
|
+
const autoscroll = document.getElementById('autoscroll');
|
|
14
|
+
let findingCount = 0;
|
|
15
|
+
let pageCount = 0;
|
|
16
|
+
|
|
17
|
+
function setStatus(status) {
|
|
18
|
+
statusBadge.className = 'badge badge-' + status;
|
|
19
|
+
statusBadge.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
|
20
|
+
if (status === 'running') {
|
|
21
|
+
stopBtn.style.display = '';
|
|
22
|
+
statsEl.style.display = '';
|
|
23
|
+
}
|
|
24
|
+
if (['completed', 'failed', 'stopped'].includes(status)) {
|
|
25
|
+
stopBtn.style.display = 'none';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
document.getElementById('clear-log-btn').addEventListener('click', () => { log.innerHTML = ''; });
|
|
30
|
+
|
|
31
|
+
stopBtn.addEventListener('click', () => {
|
|
32
|
+
stopBtn.disabled = true;
|
|
33
|
+
stopBtn.textContent = 'Stopping after current page…';
|
|
34
|
+
fetch('/api/stop/' + JOB_ID, { method: 'POST' }).catch(() => {});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Elapsed timer ─────────────────────────────────────────────────────────────
|
|
38
|
+
const statElapsed = document.getElementById('stat-elapsed');
|
|
39
|
+
let startTime = null;
|
|
40
|
+
let elapsedInterval = null;
|
|
41
|
+
|
|
42
|
+
function startTimer() {
|
|
43
|
+
if (elapsedInterval) return;
|
|
44
|
+
startTime = Date.now();
|
|
45
|
+
elapsedInterval = setInterval(() => {
|
|
46
|
+
const secs = Math.floor((Date.now() - startTime) / 1000);
|
|
47
|
+
const m = Math.floor(secs / 60);
|
|
48
|
+
const s = secs % 60;
|
|
49
|
+
statElapsed.textContent = m + ':' + String(s).padStart(2, '0');
|
|
50
|
+
}, 1000);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stopTimer() {
|
|
54
|
+
if (elapsedInterval) {
|
|
55
|
+
clearInterval(elapsedInterval);
|
|
56
|
+
elapsedInterval = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Log filtering ─────────────────────────────────────────────────────────────
|
|
61
|
+
let activeFilter = 'all';
|
|
62
|
+
|
|
63
|
+
function lineMatchesFilter(line, filter) {
|
|
64
|
+
if (filter === 'all') return true;
|
|
65
|
+
if (filter === 'finding') return line.classList.contains('log-finding');
|
|
66
|
+
if (filter === 'error') return line.classList.contains('log-error') || (line.classList.contains('log-finding') && line.classList.contains('log-critical')) || (line.classList.contains('log-finding') && line.classList.contains('log-high'));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
document.querySelectorAll('.log-filter').forEach(btn => {
|
|
71
|
+
btn.addEventListener('click', () => {
|
|
72
|
+
activeFilter = btn.dataset.filter;
|
|
73
|
+
document.querySelectorAll('.log-filter').forEach(b => b.classList.remove('active'));
|
|
74
|
+
btn.classList.add('active');
|
|
75
|
+
log.querySelectorAll('.log-line').forEach(line => {
|
|
76
|
+
line.style.display = lineMatchesFilter(line, activeFilter) ? '' : 'none';
|
|
77
|
+
});
|
|
78
|
+
if (autoscroll.checked) log.scrollTop = log.scrollHeight;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function appendLog(msg, cls) {
|
|
83
|
+
const line = document.createElement('div');
|
|
84
|
+
line.className = 'log-line' + (cls ? ' ' + cls : '');
|
|
85
|
+
line.textContent = msg;
|
|
86
|
+
line.style.display = lineMatchesFilter(line, activeFilter) ? '' : 'none';
|
|
87
|
+
log.appendChild(line);
|
|
88
|
+
if (autoscroll.checked && line.style.display !== 'none') log.scrollTop = log.scrollHeight;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── SSE stream ────────────────────────────────────────────────────────────────
|
|
92
|
+
// Pre-check actual job status before opening SSE — the job may still be queued
|
|
93
|
+
// or may have already finished if the user is loading a page for an old job.
|
|
94
|
+
function startStream() {
|
|
95
|
+
const es = new EventSource('/api/stream/' + JOB_ID);
|
|
96
|
+
startTimer();
|
|
97
|
+
|
|
98
|
+
es.addEventListener('log', e => {
|
|
99
|
+
const data = JSON.parse(e.data);
|
|
100
|
+
appendLog(data.message);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
es.addEventListener('progress', e => {
|
|
104
|
+
const data = JSON.parse(e.data);
|
|
105
|
+
pageCount++;
|
|
106
|
+
statPages.textContent = pageCount;
|
|
107
|
+
statUrl.textContent = data.url || '';
|
|
108
|
+
appendLog(data.message, 'log-progress');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
es.addEventListener('finding', e => {
|
|
112
|
+
const data = JSON.parse(e.data);
|
|
113
|
+
findingCount++;
|
|
114
|
+
statFindings.textContent = findingCount;
|
|
115
|
+
appendLog('[' + data.severity.toUpperCase() + '] ' + data.title, 'log-finding log-' + data.severity);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
es.addEventListener('complete', e => {
|
|
119
|
+
const data = JSON.parse(e.data);
|
|
120
|
+
es.close();
|
|
121
|
+
stopTimer();
|
|
122
|
+
setStatus(data.status || 'completed');
|
|
123
|
+
statFindings.textContent = data.total_findings || findingCount;
|
|
124
|
+
if (data.status === 'stopped') {
|
|
125
|
+
stoppedBanner.style.display = '';
|
|
126
|
+
if (data.session_id && data.domain) {
|
|
127
|
+
const link = document.getElementById('stopped-link');
|
|
128
|
+
link.href = '/session/' + data.domain + '/' + data.session_id;
|
|
129
|
+
document.getElementById('stopped-session-link').style.display = '';
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
completeBanner.style.display = '';
|
|
133
|
+
if (data.session_id && data.domain) {
|
|
134
|
+
document.getElementById('session-link').href = '/session/' + data.domain + '/' + data.session_id;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
es.addEventListener('error', e => {
|
|
140
|
+
if (e.data) {
|
|
141
|
+
const data = JSON.parse(e.data);
|
|
142
|
+
es.close();
|
|
143
|
+
stopTimer();
|
|
144
|
+
setStatus('failed');
|
|
145
|
+
errorBanner.style.display = '';
|
|
146
|
+
errorBanner.textContent = 'Error: ' + data.message;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
es.onerror = () => {
|
|
151
|
+
es.close();
|
|
152
|
+
stopTimer();
|
|
153
|
+
fetch('/api/status/' + JOB_ID)
|
|
154
|
+
.then(r => r.json())
|
|
155
|
+
.then(d => {
|
|
156
|
+
setStatus(d.status);
|
|
157
|
+
statFindings.textContent = d.total_findings || findingCount;
|
|
158
|
+
if (d.status === 'completed' && d.session_id) {
|
|
159
|
+
completeBanner.style.display = '';
|
|
160
|
+
document.getElementById('session-link').href = '/session/' + d.domain + '/' + d.session_id;
|
|
161
|
+
} else if (d.status === 'failed') {
|
|
162
|
+
errorBanner.style.display = '';
|
|
163
|
+
errorBanner.textContent = 'Error: ' + (d.error || 'Unknown error');
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch(() => appendLog('Connection lost. Refresh to check status.', 'log-error'));
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fetch('/api/status/' + JOB_ID)
|
|
171
|
+
.then(r => r.json())
|
|
172
|
+
.then(d => {
|
|
173
|
+
const finalStates = ['completed', 'stopped', 'failed'];
|
|
174
|
+
setStatus(d.status);
|
|
175
|
+
if (finalStates.includes(d.status)) {
|
|
176
|
+
statFindings.textContent = d.total_findings || 0;
|
|
177
|
+
if (d.status === 'completed' && d.session_id) {
|
|
178
|
+
completeBanner.style.display = '';
|
|
179
|
+
document.getElementById('session-link').href = '/session/' + d.domain + '/' + d.session_id;
|
|
180
|
+
} else if (d.status === 'stopped') {
|
|
181
|
+
stoppedBanner.style.display = '';
|
|
182
|
+
if (d.session_id && d.domain) {
|
|
183
|
+
document.getElementById('stopped-link').href = '/session/' + d.domain + '/' + d.session_id;
|
|
184
|
+
document.getElementById('stopped-session-link').style.display = '';
|
|
185
|
+
}
|
|
186
|
+
} else if (d.status === 'failed') {
|
|
187
|
+
errorBanner.style.display = '';
|
|
188
|
+
errorBanner.textContent = 'Error: ' + (d.error || 'Unknown error');
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
startStream();
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
// Status check failed — fall back to opening SSE directly
|
|
196
|
+
setStatus('running');
|
|
197
|
+
startStream();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
}());
|