qaforge-mcp 1.0.0__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 (34) hide show
  1. qaforge_mcp-1.0.0/LICENSE +21 -0
  2. qaforge_mcp-1.0.0/PKG-INFO +83 -0
  3. qaforge_mcp-1.0.0/README.md +61 -0
  4. qaforge_mcp-1.0.0/pyproject.toml +34 -0
  5. qaforge_mcp-1.0.0/qaforge_mcp/__init__.py +4 -0
  6. qaforge_mcp-1.0.0/qaforge_mcp/__main__.py +10 -0
  7. qaforge_mcp-1.0.0/qaforge_mcp/config.py +35 -0
  8. qaforge_mcp-1.0.0/qaforge_mcp/integrations/__init__.py +0 -0
  9. qaforge_mcp-1.0.0/qaforge_mcp/integrations/jira.py +66 -0
  10. qaforge_mcp-1.0.0/qaforge_mcp/integrations/k6_runner.py +137 -0
  11. qaforge_mcp-1.0.0/qaforge_mcp/integrations/playwright_runner.py +114 -0
  12. qaforge_mcp-1.0.0/qaforge_mcp/integrations/slack.py +43 -0
  13. qaforge_mcp-1.0.0/qaforge_mcp/server.py +358 -0
  14. qaforge_mcp-1.0.0/qaforge_mcp/tools/__init__.py +0 -0
  15. qaforge_mcp-1.0.0/qaforge_mcp/tools/_ai_utils.py +32 -0
  16. qaforge_mcp-1.0.0/qaforge_mcp/tools/accessibility.py +121 -0
  17. qaforge_mcp-1.0.0/qaforge_mcp/tools/browser_differ.py +104 -0
  18. qaforge_mcp-1.0.0/qaforge_mcp/tools/bug_reporter.py +133 -0
  19. qaforge_mcp-1.0.0/qaforge_mcp/tools/data_generator.py +190 -0
  20. qaforge_mcp-1.0.0/qaforge_mcp/tools/flaky_detector.py +111 -0
  21. qaforge_mcp-1.0.0/qaforge_mcp/tools/health_checker.py +119 -0
  22. qaforge_mcp-1.0.0/qaforge_mcp/tools/performance_baseline.py +107 -0
  23. qaforge_mcp-1.0.0/qaforge_mcp/tools/regression_analyzer.py +136 -0
  24. qaforge_mcp-1.0.0/qaforge_mcp/tools/report_narrator.py +136 -0
  25. qaforge_mcp-1.0.0/qaforge_mcp/tools/security_tester.py +189 -0
  26. qaforge_mcp-1.0.0/qaforge_mcp/tools/test_executor.py +91 -0
  27. qaforge_mcp-1.0.0/qaforge_mcp/tools/test_generator.py +79 -0
  28. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/PKG-INFO +83 -0
  29. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/SOURCES.txt +32 -0
  30. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/dependency_links.txt +1 -0
  31. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/entry_points.txt +2 -0
  32. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/requires.txt +7 -0
  33. qaforge_mcp-1.0.0/qaforge_mcp.egg-info/top_level.txt +1 -0
  34. qaforge_mcp-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sahil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: qaforge-mcp
3
+ Version: 1.0.0
4
+ Summary: The World's First All-in-One QA MCP Server — 12 QA tools for Claude Code
5
+ Author-email: Sahil <sahil804.2017@gmail.com>
6
+ License: MIT
7
+ Keywords: mcp,qa,testing,playwright,jira,security,k6
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: fastmcp>=2.0.0
15
+ Requires-Dist: anthropic>=0.40.0
16
+ Requires-Dist: httpx>=0.27.0
17
+ Requires-Dist: playwright>=1.45.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: faker>=26.0.0
20
+ Requires-Dist: python-dotenv>=1.0.0
21
+ Dynamic: license-file
22
+
23
+ # QAForge MCP
24
+
25
+ The world's first all-in-one QA engineering MCP server — 12 QA tools for Claude Code, in one place.
26
+
27
+ Generate test cases, run security scans, detect flaky tests, diff browsers, check accessibility, baseline performance, and more — all from natural language, inside Claude.
28
+
29
+ ## Tools
30
+
31
+ | # | Tool | What it does |
32
+ |---|------|---------------|
33
+ | 1 | `generate_test_cases` | FRD/Swagger → numbered test cases with priority + coverage gaps |
34
+ | 2 | `detect_flaky_tests` | Statistical flaky-test detection across JUnit XML runs, AI root-cause |
35
+ | 3 | `generate_security_tests` | OWASP Top 10 Postman collection + k6 security script |
36
+ | 4 | `narrate_report` | JUnit/Allure report → stakeholder-ready narrative, optional Slack post |
37
+ | 5 | `diff_browsers` | Cross-browser (Chrome/Firefox/WebKit) diffing via Playwright |
38
+ | 6 | `ai_test_executor` | Executes structured test cases against a live URL |
39
+ | 7 | `bug_report_generator` | AI bug report + optional Jira ticket creation |
40
+ | 8 | `regression_impact_analyzer` | git diff → impacted test areas + risk levels |
41
+ | 9 | `accessibility_checker` | WCAG 2.1 audit via axe-core, AI fix suggestions |
42
+ | 10 | `performance_baseline_mcp` | k6 load test, P50–P99, baseline regression alerts |
43
+ | 11 | `environment_health_checker` | Pre-test service health checks, Slack alerts |
44
+ | 12 | `test_data_generator` | Faker-based test data + edge cases + blockchain fields |
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install qaforge-mcp
50
+ playwright install chromium firefox webkit # needed for browser/accessibility tools
51
+ ```
52
+
53
+ Optionally install [k6](https://k6.io/docs/getting-started/installation/) for the performance tools.
54
+
55
+ ## Register with Claude Code
56
+
57
+ ```bash
58
+ claude mcp add --scope user qaforge -e ANTHROPIC_API_KEY=your-key-here -- python -m qaforge_mcp
59
+ ```
60
+
61
+ This makes all 12 tools available in every Claude Code session on your machine.
62
+
63
+ ### Optional integrations
64
+
65
+ Add any of these to the same `-e` flags (or a `.env` file next to your working directory) to unlock more:
66
+
67
+ | Variable | Enables |
68
+ |---|---|
69
+ | `ANTHROPIC_API_KEY` | AI-powered analysis (test generation, root-cause classification, bug reports) |
70
+ | `JIRA_URL`, `JIRA_EMAIL`, `JIRA_TOKEN`, `JIRA_PROJECT_KEY` | Auto-create Jira tickets from `bug_report_generator` |
71
+ | `SLACK_WEBHOOK` | Post test reports and environment alerts to Slack |
72
+
73
+ Tools work without any of these configured — AI-dependent tools fall back to non-AI output, and Jira/Slack features simply no-op.
74
+
75
+ ## Requirements
76
+
77
+ - Python 3.11+
78
+ - [Playwright](https://playwright.dev/) browsers for `diff_browsers`, `accessibility_checker`, `ai_test_executor`
79
+ - [k6](https://k6.io/) for `performance_baseline_mcp` and the security k6 script
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,61 @@
1
+ # QAForge MCP
2
+
3
+ The world's first all-in-one QA engineering MCP server — 12 QA tools for Claude Code, in one place.
4
+
5
+ Generate test cases, run security scans, detect flaky tests, diff browsers, check accessibility, baseline performance, and more — all from natural language, inside Claude.
6
+
7
+ ## Tools
8
+
9
+ | # | Tool | What it does |
10
+ |---|------|---------------|
11
+ | 1 | `generate_test_cases` | FRD/Swagger → numbered test cases with priority + coverage gaps |
12
+ | 2 | `detect_flaky_tests` | Statistical flaky-test detection across JUnit XML runs, AI root-cause |
13
+ | 3 | `generate_security_tests` | OWASP Top 10 Postman collection + k6 security script |
14
+ | 4 | `narrate_report` | JUnit/Allure report → stakeholder-ready narrative, optional Slack post |
15
+ | 5 | `diff_browsers` | Cross-browser (Chrome/Firefox/WebKit) diffing via Playwright |
16
+ | 6 | `ai_test_executor` | Executes structured test cases against a live URL |
17
+ | 7 | `bug_report_generator` | AI bug report + optional Jira ticket creation |
18
+ | 8 | `regression_impact_analyzer` | git diff → impacted test areas + risk levels |
19
+ | 9 | `accessibility_checker` | WCAG 2.1 audit via axe-core, AI fix suggestions |
20
+ | 10 | `performance_baseline_mcp` | k6 load test, P50–P99, baseline regression alerts |
21
+ | 11 | `environment_health_checker` | Pre-test service health checks, Slack alerts |
22
+ | 12 | `test_data_generator` | Faker-based test data + edge cases + blockchain fields |
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install qaforge-mcp
28
+ playwright install chromium firefox webkit # needed for browser/accessibility tools
29
+ ```
30
+
31
+ Optionally install [k6](https://k6.io/docs/getting-started/installation/) for the performance tools.
32
+
33
+ ## Register with Claude Code
34
+
35
+ ```bash
36
+ claude mcp add --scope user qaforge -e ANTHROPIC_API_KEY=your-key-here -- python -m qaforge_mcp
37
+ ```
38
+
39
+ This makes all 12 tools available in every Claude Code session on your machine.
40
+
41
+ ### Optional integrations
42
+
43
+ Add any of these to the same `-e` flags (or a `.env` file next to your working directory) to unlock more:
44
+
45
+ | Variable | Enables |
46
+ |---|---|
47
+ | `ANTHROPIC_API_KEY` | AI-powered analysis (test generation, root-cause classification, bug reports) |
48
+ | `JIRA_URL`, `JIRA_EMAIL`, `JIRA_TOKEN`, `JIRA_PROJECT_KEY` | Auto-create Jira tickets from `bug_report_generator` |
49
+ | `SLACK_WEBHOOK` | Post test reports and environment alerts to Slack |
50
+
51
+ Tools work without any of these configured — AI-dependent tools fall back to non-AI output, and Jira/Slack features simply no-op.
52
+
53
+ ## Requirements
54
+
55
+ - Python 3.11+
56
+ - [Playwright](https://playwright.dev/) browsers for `diff_browsers`, `accessibility_checker`, `ai_test_executor`
57
+ - [k6](https://k6.io/) for `performance_baseline_mcp` and the security k6 script
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qaforge-mcp"
7
+ version = "1.0.0"
8
+ description = "The World's First All-in-One QA MCP Server — 12 QA tools for Claude Code"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Sahil", email = "sahil804.2017@gmail.com" }]
13
+ keywords = ["mcp", "qa", "testing", "playwright", "jira", "security", "k6"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+
20
+ dependencies = [
21
+ "fastmcp>=2.0.0",
22
+ "anthropic>=0.40.0",
23
+ "httpx>=0.27.0",
24
+ "playwright>=1.45.0",
25
+ "pyyaml>=6.0",
26
+ "faker>=26.0.0",
27
+ "python-dotenv>=1.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ qaforge-mcp = "qaforge_mcp.__main__:main"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["qaforge_mcp"]
@@ -0,0 +1,4 @@
1
+ """QAForge MCP — The World's First All-in-One QA Engineering MCP Server."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Sahil"
@@ -0,0 +1,10 @@
1
+ """Entry point for QAForge MCP server."""
2
+
3
+
4
+ def main():
5
+ from .server import mcp
6
+ mcp.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,35 @@
1
+ import os
2
+ from pathlib import Path
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+
8
+ class Config:
9
+ def __init__(self):
10
+ self.anthropic_api_key: str = os.getenv("ANTHROPIC_API_KEY", "")
11
+ self.claude_model: str = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
12
+
13
+ self.jira_url: str = os.getenv("JIRA_URL", "").rstrip("/")
14
+ self.jira_email: str = os.getenv("JIRA_EMAIL", "")
15
+ self.jira_token: str = os.getenv("JIRA_TOKEN", "")
16
+ self.jira_project_key: str = os.getenv("JIRA_PROJECT_KEY", "QA")
17
+
18
+ self.slack_webhook_url: str = os.getenv("SLACK_WEBHOOK", "")
19
+
20
+ self.baseline_file: Path = Path(os.getenv("BASELINE_FILE", "qaforge_baselines.json"))
21
+
22
+ @property
23
+ def jira_configured(self) -> bool:
24
+ return bool(self.jira_url and self.jira_token)
25
+
26
+ @property
27
+ def slack_configured(self) -> bool:
28
+ return bool(self.slack_webhook_url)
29
+
30
+ @property
31
+ def ai_configured(self) -> bool:
32
+ return bool(self.anthropic_api_key)
33
+
34
+
35
+ config = Config()
File without changes
@@ -0,0 +1,66 @@
1
+ import base64
2
+ from typing import Optional
3
+ import httpx
4
+ from ..config import config
5
+
6
+
7
+ def _auth_header() -> str:
8
+ credentials = f"{config.jira_email}:{config.jira_token}"
9
+ return "Basic " + base64.b64encode(credentials.encode()).decode()
10
+
11
+
12
+ async def create_issue(
13
+ summary: str,
14
+ description: str,
15
+ issue_type: str = "Bug",
16
+ priority: str = "High",
17
+ labels: Optional[list[str]] = None,
18
+ assignee_account_id: Optional[str] = None,
19
+ ) -> dict:
20
+ if not config.jira_configured:
21
+ return {"error": "Jira not configured. Set JIRA_URL, JIRA_EMAIL, JIRA_TOKEN env vars."}
22
+
23
+ headers = {
24
+ "Authorization": _auth_header(),
25
+ "Content-Type": "application/json",
26
+ "Accept": "application/json",
27
+ }
28
+
29
+ fields: dict = {
30
+ "project": {"key": config.jira_project_key},
31
+ "summary": summary,
32
+ "description": {
33
+ "type": "doc",
34
+ "version": 1,
35
+ "content": [
36
+ {
37
+ "type": "paragraph",
38
+ "content": [{"type": "text", "text": description}],
39
+ }
40
+ ],
41
+ },
42
+ "issuetype": {"name": issue_type},
43
+ "priority": {"name": priority},
44
+ }
45
+
46
+ if labels:
47
+ fields["labels"] = labels
48
+ if assignee_account_id:
49
+ fields["assignee"] = {"accountId": assignee_account_id}
50
+
51
+ payload = {"fields": fields}
52
+
53
+ async with httpx.AsyncClient(timeout=30) as client:
54
+ resp = await client.post(
55
+ f"{config.jira_url}/rest/api/3/issue",
56
+ headers=headers,
57
+ json=payload,
58
+ )
59
+ if resp.status_code == 201:
60
+ data = resp.json()
61
+ return {
62
+ "key": data["key"],
63
+ "id": data["id"],
64
+ "url": f"{config.jira_url}/browse/{data['key']}",
65
+ }
66
+ return {"error": f"Jira API error {resp.status_code}: {resp.text[:500]}"}
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ _K6_CANDIDATES = [
10
+ "k6",
11
+ r"C:\Program Files\k6\k6.exe",
12
+ r"C:\Program Files (x86)\k6\k6.exe",
13
+ str(Path.home() / "AppData" / "Local" / "Microsoft" / "WinGet" / "Packages" / "GrafanaLabs.k6_Microsoft.Winget.Source_8wekyb3d8bbwe" / "k6.exe"),
14
+ ]
15
+
16
+
17
+ def _find_k6() -> str:
18
+ if found := shutil.which("k6"):
19
+ return found
20
+ for candidate in _K6_CANDIDATES:
21
+ if Path(candidate).exists():
22
+ return candidate
23
+ return "k6"
24
+
25
+
26
+ def generate_k6_script(
27
+ endpoints: list[dict],
28
+ vus: int = 10,
29
+ duration: str = "30s",
30
+ baseline_thresholds: Optional[dict] = None,
31
+ ) -> str:
32
+ endpoint_calls = []
33
+ for ep in endpoints:
34
+ method = ep.get("method", "GET").upper()
35
+ url = ep.get("url", "")
36
+ headers = ep.get("headers", {})
37
+ body = ep.get("body", None)
38
+
39
+ url_js = json.dumps(url)
40
+ headers_js = json.dumps(headers)
41
+ if method == "GET":
42
+ endpoint_calls.append(f' http.get({url_js}, {{headers: {headers_js}}});')
43
+ else:
44
+ body_js = json.dumps(body) if body else "null"
45
+ endpoint_calls.append(
46
+ f' http.{method.lower()}({url_js}, JSON.stringify({body_js}), {{headers: {headers_js}}});'
47
+ )
48
+
49
+ calls_block = "\n".join(endpoint_calls) if endpoint_calls else ' http.get(__ENV.TARGET_URL || "http://localhost:3000");'
50
+
51
+ thresholds = baseline_thresholds or {
52
+ "http_req_duration": ["p(90)<2000", "p(95)<3000"],
53
+ "http_req_failed": ["rate<0.05"],
54
+ }
55
+ thresholds_js = json.dumps(thresholds, indent=4)
56
+
57
+ return f"""import http from 'k6/http';
58
+ import {{ check, sleep }} from 'k6';
59
+
60
+ export const options = {{
61
+ vus: {vus},
62
+ duration: '{duration}',
63
+ thresholds: {thresholds_js},
64
+ }};
65
+
66
+ export default function () {{
67
+ {calls_block}
68
+ sleep(1);
69
+ }}
70
+ """
71
+
72
+
73
+ async def run_k6(script_content: str, output_json: bool = True) -> dict:
74
+ with tempfile.TemporaryDirectory() as tmpdir:
75
+ script_path = Path(tmpdir) / "test.js"
76
+ output_path = Path(tmpdir) / "results.json"
77
+ script_path.write_text(script_content)
78
+
79
+ cmd = [_find_k6(), "run"]
80
+ if output_json:
81
+ cmd += ["--out", f"json={output_path}"]
82
+ cmd.append(str(script_path))
83
+
84
+ try:
85
+ proc = await asyncio.create_subprocess_exec(
86
+ *cmd,
87
+ stdout=asyncio.subprocess.PIPE,
88
+ stderr=asyncio.subprocess.PIPE,
89
+ cwd=tmpdir,
90
+ )
91
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
92
+ except FileNotFoundError:
93
+ return {"error": "k6 not installed. Install from https://k6.io/docs/getting-started/installation/"}
94
+ except asyncio.TimeoutError:
95
+ proc.kill()
96
+ return {"error": "k6 run timed out after 5 minutes"}
97
+
98
+ stdout_text = stdout.decode(errors="replace")
99
+ stderr_text = stderr.decode(errors="replace")
100
+
101
+ metrics = _parse_k6_stdout(stdout_text)
102
+ metrics["exit_code"] = proc.returncode
103
+ metrics["stdout"] = stdout_text[-3000:]
104
+ if proc.returncode != 0:
105
+ metrics["stderr"] = stderr_text[-1000:]
106
+
107
+ return metrics
108
+
109
+
110
+ def _parse_k6_stdout(output: str) -> dict:
111
+ metrics: dict = {}
112
+ for line in output.splitlines():
113
+ if "http_req_duration" in line and "avg=" in line:
114
+ metrics["raw_duration_line"] = line.strip()
115
+ for part in line.split():
116
+ for key in ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"]:
117
+ if part.startswith(key + "="):
118
+ val_str = part.split("=")[1].replace("ms", "").replace("s", "")
119
+ try:
120
+ metrics[key.replace("(", "").replace(")", "")] = float(val_str)
121
+ except ValueError:
122
+ pass
123
+ elif "http_req_failed" in line and "rate=" in line:
124
+ for part in line.split():
125
+ if part.startswith("rate="):
126
+ try:
127
+ metrics["failure_rate"] = float(part.split("=")[1].rstrip("%")) / 100
128
+ except ValueError:
129
+ pass
130
+ elif "http_reqs" in line and "rate=" in line:
131
+ for part in line.split():
132
+ if part.startswith("rate="):
133
+ try:
134
+ metrics["rps"] = float(part.split("=")[1].split("/")[0])
135
+ except ValueError:
136
+ pass
137
+ return metrics
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import base64
3
+ from typing import Optional
4
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
5
+
6
+
7
+ async def get_page_snapshot(url: str, browser_type: str = "chromium") -> dict:
8
+ async with async_playwright() as p:
9
+ launcher = getattr(p, browser_type)
10
+ browser: Browser = await launcher.launch(headless=True)
11
+ context: BrowserContext = await browser.new_context()
12
+ page: Page = await context.new_page()
13
+ errors: list[str] = []
14
+ page.on("pageerror", lambda e: errors.append(str(e)))
15
+
16
+ try:
17
+ await page.goto(url, wait_until="networkidle", timeout=30000)
18
+ title = await page.title()
19
+ screenshot_bytes = await page.screenshot(full_page=True)
20
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
21
+ dom_snapshot = await page.evaluate("document.documentElement.outerHTML")
22
+ perf = await page.evaluate("""() => {
23
+ const nav = performance.getEntriesByType('navigation')[0];
24
+ return nav ? {
25
+ dom_content_loaded: Math.round(nav.domContentLoadedEventEnd),
26
+ load: Math.round(nav.loadEventEnd)
27
+ } : {};
28
+ }""")
29
+ return {
30
+ "browser": browser_type,
31
+ "url": url,
32
+ "title": title,
33
+ "screenshot_b64": screenshot_b64,
34
+ "dom_length": len(dom_snapshot),
35
+ "dom_snippet": dom_snapshot[:2000],
36
+ "js_errors": errors,
37
+ "performance_ms": perf,
38
+ }
39
+ except Exception as e:
40
+ return {"browser": browser_type, "url": url, "error": str(e)}
41
+ finally:
42
+ await browser.close()
43
+
44
+
45
+ async def run_axe_audit(url: str) -> dict:
46
+ async with async_playwright() as p:
47
+ browser: Browser = await p.chromium.launch(headless=True)
48
+ page: Page = await browser.new_page()
49
+ try:
50
+ await page.goto(url, wait_until="networkidle", timeout=30000)
51
+ await page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js")
52
+ await page.wait_for_function("typeof axe !== 'undefined'", timeout=10000)
53
+ results = await page.evaluate("""async () => {
54
+ const result = await axe.run();
55
+ return {
56
+ violations: result.violations,
57
+ passes: result.passes.length,
58
+ incomplete: result.incomplete.length,
59
+ inapplicable: result.inapplicable.length
60
+ };
61
+ }""")
62
+ return results
63
+ except Exception as e:
64
+ return {"error": str(e), "violations": []}
65
+ finally:
66
+ await browser.close()
67
+
68
+
69
+ async def execute_steps(url: str, steps: list[str]) -> dict:
70
+ """Execute a list of natural-language-like Playwright steps on a URL."""
71
+ async with async_playwright() as p:
72
+ browser: Browser = await p.chromium.launch(headless=True)
73
+ page: Page = await browser.new_page()
74
+ executed: list[dict] = []
75
+ try:
76
+ await page.goto(url, wait_until="networkidle", timeout=30000)
77
+ for step in steps:
78
+ step_lower = step.lower().strip()
79
+ try:
80
+ if step_lower.startswith("click "):
81
+ selector = step[6:].strip().strip("'\"")
82
+ await page.click(selector, timeout=10000)
83
+ executed.append({"step": step, "status": "pass"})
84
+ elif step_lower.startswith("fill "):
85
+ parts = step[5:].split(" with ", 1)
86
+ if len(parts) == 2:
87
+ selector = parts[0].strip().strip("'\"")
88
+ value = parts[1].strip().strip("'\"")
89
+ await page.fill(selector, value, timeout=10000)
90
+ executed.append({"step": step, "status": "pass"})
91
+ elif step_lower.startswith("assert visible "):
92
+ selector = step[15:].strip().strip("'\"")
93
+ await page.wait_for_selector(selector, state="visible", timeout=10000)
94
+ executed.append({"step": step, "status": "pass"})
95
+ elif step_lower.startswith("assert text "):
96
+ text = step[12:].strip().strip("'\"")
97
+ await page.wait_for_function(
98
+ f"document.body.innerText.includes({repr(text)})", timeout=10000
99
+ )
100
+ executed.append({"step": step, "status": "pass"})
101
+ elif step_lower.startswith("navigate "):
102
+ nav_url = step[9:].strip()
103
+ await page.goto(nav_url, wait_until="networkidle", timeout=30000)
104
+ executed.append({"step": step, "status": "pass"})
105
+ else:
106
+ executed.append({"step": step, "status": "skipped", "reason": "unrecognized step"})
107
+ except Exception as e:
108
+ executed.append({"step": step, "status": "fail", "error": str(e)})
109
+ final_url = page.url
110
+ return {"steps": executed, "final_url": final_url}
111
+ except Exception as e:
112
+ return {"steps": executed, "error": str(e)}
113
+ finally:
114
+ await browser.close()
@@ -0,0 +1,43 @@
1
+ from typing import Optional
2
+ import httpx
3
+ from ..config import config
4
+
5
+
6
+ async def send_message(text: str, blocks: Optional[list] = None) -> dict:
7
+ if not config.slack_configured:
8
+ return {"error": "Slack not configured. Set SLACK_WEBHOOK env var."}
9
+
10
+ payload: dict = {"text": text}
11
+ if blocks:
12
+ payload["blocks"] = blocks
13
+
14
+ async with httpx.AsyncClient(timeout=15) as client:
15
+ resp = await client.post(config.slack_webhook_url, json=payload)
16
+ if resp.status_code == 200:
17
+ return {"status": "sent"}
18
+ return {"error": f"Slack error {resp.status_code}: {resp.text}"}
19
+
20
+
21
+ async def send_qa_report(summary: str, pass_count: int, fail_count: int, channel_note: str = "") -> dict:
22
+ blocks = [
23
+ {
24
+ "type": "header",
25
+ "text": {"type": "plain_text", "text": "QAForge Test Report"},
26
+ },
27
+ {
28
+ "type": "section",
29
+ "fields": [
30
+ {"type": "mrkdwn", "text": f"*Passed:* {pass_count}"},
31
+ {"type": "mrkdwn", "text": f"*Failed:* {fail_count}"},
32
+ ],
33
+ },
34
+ {
35
+ "type": "section",
36
+ "text": {"type": "mrkdwn", "text": summary},
37
+ },
38
+ ]
39
+ if channel_note:
40
+ blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": channel_note}]})
41
+
42
+ return await send_message(text=f"QA Report: {pass_count} passed, {fail_count} failed", blocks=blocks)
43
+