qaforge-mcp 1.0.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.
- qaforge_mcp/__init__.py +4 -0
- qaforge_mcp/__main__.py +10 -0
- qaforge_mcp/config.py +35 -0
- qaforge_mcp/integrations/__init__.py +0 -0
- qaforge_mcp/integrations/jira.py +66 -0
- qaforge_mcp/integrations/k6_runner.py +137 -0
- qaforge_mcp/integrations/playwright_runner.py +114 -0
- qaforge_mcp/integrations/slack.py +43 -0
- qaforge_mcp/server.py +358 -0
- qaforge_mcp/tools/__init__.py +0 -0
- qaforge_mcp/tools/_ai_utils.py +32 -0
- qaforge_mcp/tools/accessibility.py +121 -0
- qaforge_mcp/tools/browser_differ.py +104 -0
- qaforge_mcp/tools/bug_reporter.py +133 -0
- qaforge_mcp/tools/data_generator.py +190 -0
- qaforge_mcp/tools/flaky_detector.py +111 -0
- qaforge_mcp/tools/health_checker.py +119 -0
- qaforge_mcp/tools/performance_baseline.py +107 -0
- qaforge_mcp/tools/regression_analyzer.py +136 -0
- qaforge_mcp/tools/report_narrator.py +136 -0
- qaforge_mcp/tools/security_tester.py +189 -0
- qaforge_mcp/tools/test_executor.py +91 -0
- qaforge_mcp/tools/test_generator.py +79 -0
- qaforge_mcp-1.0.0.dist-info/METADATA +83 -0
- qaforge_mcp-1.0.0.dist-info/RECORD +29 -0
- qaforge_mcp-1.0.0.dist-info/WHEEL +5 -0
- qaforge_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- qaforge_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- qaforge_mcp-1.0.0.dist-info/top_level.txt +1 -0
qaforge_mcp/__init__.py
ADDED
qaforge_mcp/__main__.py
ADDED
qaforge_mcp/config.py
ADDED
|
@@ -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
|
+
|