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.
- qaforge_mcp-1.0.0/LICENSE +21 -0
- qaforge_mcp-1.0.0/PKG-INFO +83 -0
- qaforge_mcp-1.0.0/README.md +61 -0
- qaforge_mcp-1.0.0/pyproject.toml +34 -0
- qaforge_mcp-1.0.0/qaforge_mcp/__init__.py +4 -0
- qaforge_mcp-1.0.0/qaforge_mcp/__main__.py +10 -0
- qaforge_mcp-1.0.0/qaforge_mcp/config.py +35 -0
- qaforge_mcp-1.0.0/qaforge_mcp/integrations/__init__.py +0 -0
- qaforge_mcp-1.0.0/qaforge_mcp/integrations/jira.py +66 -0
- qaforge_mcp-1.0.0/qaforge_mcp/integrations/k6_runner.py +137 -0
- qaforge_mcp-1.0.0/qaforge_mcp/integrations/playwright_runner.py +114 -0
- qaforge_mcp-1.0.0/qaforge_mcp/integrations/slack.py +43 -0
- qaforge_mcp-1.0.0/qaforge_mcp/server.py +358 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/__init__.py +0 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/_ai_utils.py +32 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/accessibility.py +121 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/browser_differ.py +104 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/bug_reporter.py +133 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/data_generator.py +190 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/flaky_detector.py +111 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/health_checker.py +119 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/performance_baseline.py +107 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/regression_analyzer.py +136 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/report_narrator.py +136 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/security_tester.py +189 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/test_executor.py +91 -0
- qaforge_mcp-1.0.0/qaforge_mcp/tools/test_generator.py +79 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/PKG-INFO +83 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/SOURCES.txt +32 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/dependency_links.txt +1 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/entry_points.txt +2 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/requires.txt +7 -0
- qaforge_mcp-1.0.0/qaforge_mcp.egg-info/top_level.txt +1 -0
- 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,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
|
+
|