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.
Files changed (75) hide show
  1. {qa_agent-0.2.2/qa_agent.egg-info → qa_agent-0.2.3}/PKG-INFO +3 -2
  2. {qa_agent-0.2.2 → qa_agent-0.2.3}/README.md +2 -1
  3. {qa_agent-0.2.2 → qa_agent-0.2.3}/pyproject.toml +1 -1
  4. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/__init__.py +1 -1
  5. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/agent.py +85 -29
  6. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/cli.py +3 -3
  7. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/config.py +1 -1
  8. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/console.py +4 -0
  9. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/keyboard.py +8 -4
  10. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/server.py +1 -1
  11. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/static/app.js +25 -0
  12. qa_agent-0.2.3/qa_agent/web/static/index.js +91 -0
  13. qa_agent-0.2.3/qa_agent/web/static/run.js +200 -0
  14. qa_agent-0.2.3/qa_agent/web/static/session.js +21 -0
  15. qa_agent-0.2.2/qa_agent/web/templates/sessions.html → qa_agent-0.2.3/qa_agent/web/static/sessions.js +0 -25
  16. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/index.html +7 -94
  17. qa_agent-0.2.3/qa_agent/web/templates/run.html +67 -0
  18. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/session.html +2 -21
  19. qa_agent-0.2.3/qa_agent/web/templates/sessions.html +22 -0
  20. {qa_agent-0.2.2 → qa_agent-0.2.3/qa_agent.egg-info}/PKG-INFO +3 -2
  21. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/SOURCES.txt +4 -0
  22. qa_agent-0.2.2/qa_agent/web/templates/run.html +0 -246
  23. {qa_agent-0.2.2 → qa_agent-0.2.3}/LICENSE +0 -0
  24. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/__main__.py +0 -0
  25. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/ai_planner.py +0 -0
  26. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/llm_client.py +0 -0
  27. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/models.py +0 -0
  28. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/plan_cache.py +0 -0
  29. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/__init__.py +0 -0
  30. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/base.py +0 -0
  31. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/json_reporter.py +0 -0
  32. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/markdown.py +0 -0
  33. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/reporters/pdf.py +0 -0
  34. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/__init__.py +0 -0
  35. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/accessibility.py +0 -0
  36. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/base.py +0 -0
  37. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/custom.py +0 -0
  38. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/errors.py +0 -0
  39. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/forms.py +0 -0
  40. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/mouse.py +0 -0
  41. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/testers/wcag_compliance.py +0 -0
  42. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/__init__.py +0 -0
  43. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/static/style.css +0 -0
  44. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent/web/templates/base.html +0 -0
  45. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/dependency_links.txt +0 -0
  46. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/entry_points.txt +0 -0
  47. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/requires.txt +0 -0
  48. {qa_agent-0.2.2 → qa_agent-0.2.3}/qa_agent.egg-info/top_level.txt +0 -0
  49. {qa_agent-0.2.2 → qa_agent-0.2.3}/setup.cfg +0 -0
  50. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/__init__.py +0 -0
  51. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/_cli_exit_helper.py +0 -0
  52. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/conftest.py +0 -0
  53. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/__init__.py +0 -0
  54. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/test_smoke.py +0 -0
  55. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/integration/test_target_coverage.py +0 -0
  56. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_agent.py +0 -0
  57. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_ai_planner.py +0 -0
  58. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_cli.py +0 -0
  59. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_config.py +0 -0
  60. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_llm_client.py +0 -0
  61. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_models.py +0 -0
  62. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_packaging.py +0 -0
  63. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_plan_cache.py +0 -0
  64. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_plan_warnings.py +0 -0
  65. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/test_reporters.py +0 -0
  66. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/__init__.py +0 -0
  67. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_accessibility.py +0 -0
  68. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_base.py +0 -0
  69. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_custom.py +0 -0
  70. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_errors.py +0 -0
  71. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_forms.py +0 -0
  72. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_keyboard.py +0 -0
  73. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/testers/test_mouse.py +0 -0
  74. {qa_agent-0.2.2 → qa_agent-0.2.3}/tests/web/__init__.py +0 -0
  75. {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.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
- ## Table of Contents
93
+ <details>
94
+ <summary><strong>Table of Contents</strong></summary>
94
95
 
95
96
  - [Features](#features)
96
97
  - [Installation](#installation)
@@ -23,7 +23,8 @@ Need targeted tests? Pass plain-English instructions and an LLM generates custom
23
23
 
24
24
  ---
25
25
 
26
- ## Table of Contents
26
+ <details>
27
+ <summary><strong>Table of Contents</strong></summary>
27
28
 
28
29
  - [Features](#features)
29
30
  - [Installation](#installation)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qa-agent"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Agentic exploratory QA testing for web applications"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -11,4 +11,4 @@ try:
11
11
  __version__ = version("qa-agent")
12
12
  except PackageNotFoundError:
13
13
  # Package not installed (e.g. running from source without install)
14
- __version__ = "0.2.2"
14
+ __version__ = "0.2.3"
@@ -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
- "Tip: use --auth-file with a JSON file containing "
268
- "username_selector, password_selector, and submit_selector fields."
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"Warning: Could not fill username field: {e}"
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.print_progress(msg)
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"Warning: Could not fill password field: {e}"
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.print_progress(msg)
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"Warning: Could not submit login form: {e}"
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.print_progress(msg)
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
- new_urls = self._discover_links(url)
348
- for new_url in new_urls:
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.print_progress(f"Error loading page: {e}")
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
- def _discover_links(self, current_url: str) -> list[str]:
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
- links = self.page.evaluate("""() => {
560
+ raw = self.page.evaluate("""() => {
520
561
  const links = document.querySelectorAll('a[href]');
521
- return Array.from(links).map(a => a.href).filter(href =>
522
- href &&
523
- !href.startsWith('javascript:') &&
524
- !href.startsWith('mailto:') &&
525
- !href.startsWith('tel:') &&
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
- valid_links = []
574
+ seen: set[str] = set()
575
+ valid_links: list[dict] = []
531
576
  current_domain = urlparse(current_url).netloc
532
577
 
533
- for link in links:
534
- parsed = urlparse(link)
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
- valid_links.append(normalized)
588
+ if normalized not in seen:
589
+ seen.add(normalized)
590
+ valid_links.append({'href': normalized, 'text': item['text']})
546
591
 
547
- return list(set(valid_links))
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=f"Focus cycles through only {len(visited_indices)} elements repeatedly",
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.HIGH,
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)} elements",
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
+ }());