altqa-cli 0.1.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.
- altqa/__init__.py +3 -0
- altqa/agents/__init__.py +0 -0
- altqa/agents/accessibility_auditor.py +125 -0
- altqa/agents/assertion_interpreter.py +145 -0
- altqa/agents/data_generator.py +184 -0
- altqa/agents/failure_analyzer.py +180 -0
- altqa/agents/flow_generator.py +147 -0
- altqa/agents/natural_test.py +124 -0
- altqa/agents/prompts/accessibility_audit.md +42 -0
- altqa/agents/prompts/analyze_failure.md +44 -0
- altqa/agents/prompts/analyze_ticket.md +28 -0
- altqa/agents/prompts/generate_flows.md +52 -0
- altqa/agents/prompts/interpret_test_case.md +53 -0
- altqa/agents/prompts/natural_test.md +61 -0
- altqa/agents/prompts/visual_review.md +38 -0
- altqa/agents/root_cause_analyzer.py +198 -0
- altqa/agents/selector_healer.py +104 -0
- altqa/agents/test_case_interpreter.py +232 -0
- altqa/agents/ticket_analyzer.py +120 -0
- altqa/agents/visual_reviewer.py +135 -0
- altqa/browser/__init__.py +0 -0
- altqa/browser/actions.py +277 -0
- altqa/browser/health.py +362 -0
- altqa/browser/manager.py +141 -0
- altqa/browser/network.py +172 -0
- altqa/browser/session.py +60 -0
- altqa/cli/__init__.py +0 -0
- altqa/cli/audit_cmd.py +128 -0
- altqa/cli/chat_cmd.py +182 -0
- altqa/cli/config_cmd.py +56 -0
- altqa/cli/crawl_cmd.py +122 -0
- altqa/cli/flow_cmd.py +263 -0
- altqa/cli/init_cmd.py +238 -0
- altqa/cli/jira_cmd.py +213 -0
- altqa/cli/main.py +224 -0
- altqa/cli/run_cmd.py +418 -0
- altqa/cli/test_cmd.py +185 -0
- altqa/config/__init__.py +0 -0
- altqa/config/manager.py +40 -0
- altqa/config/models.py +45 -0
- altqa/config/schema.py +51 -0
- altqa/crawler/__init__.py +0 -0
- altqa/crawler/crawler.py +173 -0
- altqa/crawler/graph.py +168 -0
- altqa/crawler/page_analyzer.py +142 -0
- altqa/flows/__init__.py +0 -0
- altqa/flows/assertions.py +133 -0
- altqa/flows/generator.py +170 -0
- altqa/flows/parser.py +180 -0
- altqa/flows/runner.py +228 -0
- altqa/flows/variables.py +72 -0
- altqa/jira/__init__.py +0 -0
- altqa/jira/agent.py +212 -0
- altqa/jira/attachments.py +76 -0
- altqa/jira/bug_reporter.py +254 -0
- altqa/jira/test_case_parser.py +195 -0
- altqa/jira/ticket_parser.py +100 -0
- altqa/pipeline/__init__.py +0 -0
- altqa/pipeline/orchestrator.py +353 -0
- altqa/pipeline/result.py +107 -0
- altqa/utils/__init__.py +0 -0
- altqa/utils/cleanup.py +25 -0
- altqa/utils/logger.py +53 -0
- altqa_cli-0.1.0.dist-info/METADATA +206 -0
- altqa_cli-0.1.0.dist-info/RECORD +68 -0
- altqa_cli-0.1.0.dist-info/WHEEL +4 -0
- altqa_cli-0.1.0.dist-info/entry_points.txt +2 -0
- altqa_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
altqa/__init__.py
ADDED
altqa/agents/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""AccessibilityAuditorAgent — AI-powered WCAG accessibility auditing.
|
|
2
|
+
|
|
3
|
+
Crawls pages and uses AI to identify accessibility violations, mapping
|
|
4
|
+
them to WCAG 2.1 criteria with concrete fix suggestions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agno.agent import Agent
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from altqa.config.models import resolve_model
|
|
18
|
+
from altqa.config.schema import AltQAConfig
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("altqa.agents")
|
|
21
|
+
|
|
22
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "accessibility_audit.md"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class A11yIssue(BaseModel):
|
|
26
|
+
severity: str = "warning"
|
|
27
|
+
wcag_criterion: str = ""
|
|
28
|
+
description: str = ""
|
|
29
|
+
element: str = ""
|
|
30
|
+
fix: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class A11yAuditResult(BaseModel):
|
|
34
|
+
page_url: str = ""
|
|
35
|
+
score: str = "unknown"
|
|
36
|
+
issues: list[A11yIssue] = Field(default_factory=list)
|
|
37
|
+
summary: str = ""
|
|
38
|
+
passed_checks: list[str] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AccessibilityAuditorAgent:
|
|
42
|
+
"""Audit web pages for WCAG 2.1 accessibility compliance."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: AltQAConfig) -> None:
|
|
45
|
+
self.config = config
|
|
46
|
+
self.model = resolve_model(config.llm)
|
|
47
|
+
self._system_prompt = _PROMPT_PATH.read_text()
|
|
48
|
+
|
|
49
|
+
def audit_html(self, html: str, page_url: str = "") -> A11yAuditResult:
|
|
50
|
+
"""Audit raw HTML content for accessibility issues."""
|
|
51
|
+
html_snippet = html[:6000] if len(html) > 6000 else html
|
|
52
|
+
|
|
53
|
+
user_prompt = (
|
|
54
|
+
f"## Page to audit\n"
|
|
55
|
+
f"**URL:** {page_url}\n\n"
|
|
56
|
+
f"```html\n{html_snippet}\n```"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
from agno.utils.log import logger as agno_logger
|
|
60
|
+
prev_handlers = agno_logger.handlers[:]
|
|
61
|
+
prev_propagate = agno_logger.propagate
|
|
62
|
+
agno_logger.handlers = [logging.NullHandler()]
|
|
63
|
+
agno_logger.propagate = False
|
|
64
|
+
try:
|
|
65
|
+
agent = Agent(
|
|
66
|
+
model=self.model,
|
|
67
|
+
instructions=[self._system_prompt],
|
|
68
|
+
markdown=False,
|
|
69
|
+
)
|
|
70
|
+
response = agent.run(user_prompt)
|
|
71
|
+
content = _clean(response.content or "{}")
|
|
72
|
+
data = json.loads(content)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
log.warning("Accessibility audit failed for %s: %s", page_url, exc)
|
|
75
|
+
return A11yAuditResult(page_url=page_url, summary=f"Audit failed: {exc}")
|
|
76
|
+
finally:
|
|
77
|
+
agno_logger.handlers = prev_handlers
|
|
78
|
+
agno_logger.propagate = prev_propagate
|
|
79
|
+
|
|
80
|
+
issues = [
|
|
81
|
+
A11yIssue(
|
|
82
|
+
severity=i.get("severity", "warning"),
|
|
83
|
+
wcag_criterion=i.get("wcag_criterion", ""),
|
|
84
|
+
description=i.get("description", ""),
|
|
85
|
+
element=i.get("element", ""),
|
|
86
|
+
fix=i.get("fix", ""),
|
|
87
|
+
)
|
|
88
|
+
for i in data.get("issues", [])
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
return A11yAuditResult(
|
|
92
|
+
page_url=page_url,
|
|
93
|
+
score=data.get("score", "unknown"),
|
|
94
|
+
issues=issues,
|
|
95
|
+
summary=data.get("summary", ""),
|
|
96
|
+
passed_checks=data.get("passed_checks", []),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def audit_page(self, page, page_url: str = "") -> A11yAuditResult:
|
|
100
|
+
"""Audit a live Playwright page."""
|
|
101
|
+
html = page.content()
|
|
102
|
+
url = page_url or page.url
|
|
103
|
+
return self.audit_html(html, url)
|
|
104
|
+
|
|
105
|
+
def audit_many(
|
|
106
|
+
self,
|
|
107
|
+
pages_html: list[tuple[str, str]],
|
|
108
|
+
) -> list[A11yAuditResult]:
|
|
109
|
+
"""Audit multiple pages. Each tuple is (html, page_url)."""
|
|
110
|
+
results: list[A11yAuditResult] = []
|
|
111
|
+
for html, url in pages_html:
|
|
112
|
+
result = self.audit_html(html, url)
|
|
113
|
+
results.append(result)
|
|
114
|
+
log.info("A11y audit %s: score=%s, %d issues", url, result.score, len(result.issues))
|
|
115
|
+
return results
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _clean(content: str) -> str:
|
|
119
|
+
content = content.strip()
|
|
120
|
+
if content.startswith("```"):
|
|
121
|
+
first_newline = content.index("\n") if "\n" in content else 3
|
|
122
|
+
content = content[first_newline + 1:]
|
|
123
|
+
if content.endswith("```"):
|
|
124
|
+
content = content[:-3]
|
|
125
|
+
return content.strip()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""AssertionInterpreterAgent — convert natural language assertions to concrete checks.
|
|
2
|
+
|
|
3
|
+
Users write:
|
|
4
|
+
assertions:
|
|
5
|
+
- "the success toast message should appear"
|
|
6
|
+
- "price should be greater than 0"
|
|
7
|
+
|
|
8
|
+
This agent converts them into structured assertions the engine can execute.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agno.agent import Agent
|
|
18
|
+
|
|
19
|
+
from altqa.config.models import resolve_model
|
|
20
|
+
from altqa.config.schema import AltQAConfig
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("altqa.agents")
|
|
23
|
+
|
|
24
|
+
NL_ASSERTION_PROMPT = """\
|
|
25
|
+
You are a Playwright test assertion expert. Convert natural language assertions into structured checks.
|
|
26
|
+
|
|
27
|
+
Available assertion types:
|
|
28
|
+
- url_contains: check if the current URL contains a string. Fields: value
|
|
29
|
+
- url_equals: check if the URL exactly matches. Fields: value
|
|
30
|
+
- element_visible: check if an element is visible. Fields: selector
|
|
31
|
+
- element_absent: check if an element is NOT present. Fields: selector
|
|
32
|
+
- text_equals: check if an element's text exactly matches. Fields: selector, value
|
|
33
|
+
- text_contains: check if an element's text contains a substring. Fields: selector, value
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
1. Choose the most appropriate assertion type.
|
|
37
|
+
2. Use robust CSS selectors (prefer ID, name, role, text content over class names).
|
|
38
|
+
3. If the assertion mentions "should appear" or "should be visible", use element_visible.
|
|
39
|
+
4. If it mentions "should not appear" or "should be gone", use element_absent.
|
|
40
|
+
5. If it mentions URL or redirect, use url_contains.
|
|
41
|
+
6. If it mentions specific text content, use text_contains or text_equals.
|
|
42
|
+
7. For complex assertions that don't map directly, use element_visible with the best selector.
|
|
43
|
+
|
|
44
|
+
Return ONLY valid JSON — an array of assertion objects:
|
|
45
|
+
```json
|
|
46
|
+
[
|
|
47
|
+
{"type": "element_visible", "selector": "#success-toast"},
|
|
48
|
+
{"type": "text_contains", "selector": ".price", "value": "$"}
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AssertionInterpreterAgent:
|
|
55
|
+
"""Convert natural language assertions to structured assertion defs."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: AltQAConfig) -> None:
|
|
58
|
+
self.config = config
|
|
59
|
+
self.model = resolve_model(config.llm)
|
|
60
|
+
|
|
61
|
+
def interpret(self, nl_assertion: str) -> list[dict[str, str]]:
|
|
62
|
+
"""Convert a single NL assertion into one or more structured assertions."""
|
|
63
|
+
user_prompt = f"Convert this assertion to a structured check:\n\n\"{nl_assertion}\""
|
|
64
|
+
|
|
65
|
+
from agno.utils.log import logger as agno_logger
|
|
66
|
+
prev_handlers = agno_logger.handlers[:]
|
|
67
|
+
prev_propagate = agno_logger.propagate
|
|
68
|
+
agno_logger.handlers = [logging.NullHandler()]
|
|
69
|
+
agno_logger.propagate = False
|
|
70
|
+
try:
|
|
71
|
+
agent = Agent(
|
|
72
|
+
model=self.model,
|
|
73
|
+
instructions=[NL_ASSERTION_PROMPT],
|
|
74
|
+
markdown=False,
|
|
75
|
+
)
|
|
76
|
+
response = agent.run(user_prompt)
|
|
77
|
+
content = _clean(response.content or "[]")
|
|
78
|
+
data = json.loads(content)
|
|
79
|
+
if isinstance(data, dict):
|
|
80
|
+
data = [data]
|
|
81
|
+
return data
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
log.debug("NL assertion interpretation failed: %s", exc)
|
|
84
|
+
return self._fallback(nl_assertion)
|
|
85
|
+
finally:
|
|
86
|
+
agno_logger.handlers = prev_handlers
|
|
87
|
+
agno_logger.propagate = prev_propagate
|
|
88
|
+
|
|
89
|
+
def interpret_many(self, assertions: list[str]) -> list[dict[str, str]]:
|
|
90
|
+
"""Convert multiple NL assertions."""
|
|
91
|
+
results: list[dict[str, str]] = []
|
|
92
|
+
for nl in assertions:
|
|
93
|
+
results.extend(self.interpret(nl))
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
def _fallback(self, text: str) -> list[dict[str, str]]:
|
|
97
|
+
"""Heuristic fallback for common assertion patterns."""
|
|
98
|
+
lower = text.lower()
|
|
99
|
+
|
|
100
|
+
if "visible" in lower or "appear" in lower or "show" in lower:
|
|
101
|
+
selector = self._guess_selector(lower)
|
|
102
|
+
return [{"type": "element_visible", "selector": selector}]
|
|
103
|
+
|
|
104
|
+
if "not" in lower and ("visible" in lower or "appear" in lower or "present" in lower):
|
|
105
|
+
selector = self._guess_selector(lower)
|
|
106
|
+
return [{"type": "element_absent", "selector": selector}]
|
|
107
|
+
|
|
108
|
+
if "url" in lower or "redirect" in lower or "navigate" in lower:
|
|
109
|
+
value = self._extract_url_hint(lower)
|
|
110
|
+
return [{"type": "url_contains", "value": value}]
|
|
111
|
+
|
|
112
|
+
if "text" in lower or "contain" in lower or "say" in lower:
|
|
113
|
+
return [{"type": "element_visible", "selector": "body"}]
|
|
114
|
+
|
|
115
|
+
return [{"type": "element_visible", "selector": "body"}]
|
|
116
|
+
|
|
117
|
+
def _guess_selector(self, text: str) -> str:
|
|
118
|
+
if "success" in text or "toast" in text:
|
|
119
|
+
return ".success, .toast, [role='alert'], #success"
|
|
120
|
+
if "error" in text or "alert" in text:
|
|
121
|
+
return ".error, .alert, [role='alert']"
|
|
122
|
+
if "modal" in text or "dialog" in text:
|
|
123
|
+
return "[role='dialog'], .modal"
|
|
124
|
+
if "button" in text:
|
|
125
|
+
return "button"
|
|
126
|
+
return "body"
|
|
127
|
+
|
|
128
|
+
def _extract_url_hint(self, text: str) -> str:
|
|
129
|
+
if "dashboard" in text:
|
|
130
|
+
return "/dashboard"
|
|
131
|
+
if "login" in text:
|
|
132
|
+
return "/login"
|
|
133
|
+
if "home" in text:
|
|
134
|
+
return "/"
|
|
135
|
+
return "/"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clean(content: str) -> str:
|
|
139
|
+
content = content.strip()
|
|
140
|
+
if content.startswith("```"):
|
|
141
|
+
first_newline = content.index("\n") if "\n" in content else 3
|
|
142
|
+
content = content[first_newline + 1:]
|
|
143
|
+
if content.endswith("```"):
|
|
144
|
+
content = content[:-3]
|
|
145
|
+
return content.strip()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""TestDataGeneratorAgent — AI-powered realistic test data for forms.
|
|
2
|
+
|
|
3
|
+
Instead of hardcoded "test123" values, generates contextual test data:
|
|
4
|
+
- Valid data for happy paths
|
|
5
|
+
- Invalid data for negative tests (wrong formats, boundary values)
|
|
6
|
+
- Edge case data (long strings, special chars, XSS, SQLi)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from agno.agent import Agent
|
|
16
|
+
|
|
17
|
+
from altqa.config.models import resolve_model
|
|
18
|
+
from altqa.config.schema import AltQAConfig
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("altqa.agents")
|
|
21
|
+
|
|
22
|
+
DATA_GEN_PROMPT = """\
|
|
23
|
+
You are a QA test data expert. Generate realistic test data for form fields.
|
|
24
|
+
|
|
25
|
+
Given a list of form fields (with their names/types), generate multiple data sets:
|
|
26
|
+
1. **valid**: Realistic data that should pass all validation
|
|
27
|
+
2. **invalid**: Data that should trigger validation errors (wrong formats, missing required)
|
|
28
|
+
3. **boundary**: Edge cases (empty strings, max length, min/max numbers, unicode, special chars)
|
|
29
|
+
4. **security**: Potentially dangerous inputs (SQL injection, XSS, path traversal) for security testing
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Use realistic, contextual values (not "test123")
|
|
33
|
+
- For email fields: use real-looking emails for valid, malformed for invalid
|
|
34
|
+
- For password fields: use strong passwords for valid, too-short for invalid
|
|
35
|
+
- For numeric fields: use 0, -1, MAX_INT, decimals for boundary
|
|
36
|
+
- For text fields: use very long strings (200+ chars), unicode, emojis for boundary
|
|
37
|
+
- Security data must be actual payloads (e.g. `' OR 1=1--`, `<script>alert(1)</script>`)
|
|
38
|
+
|
|
39
|
+
Return ONLY valid JSON:
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"valid": [
|
|
43
|
+
{"field_name": "value", ...}
|
|
44
|
+
],
|
|
45
|
+
"invalid": [
|
|
46
|
+
{"field_name": "bad_value", "_expect_error": "description of expected validation error", ...}
|
|
47
|
+
],
|
|
48
|
+
"boundary": [
|
|
49
|
+
{"field_name": "edge_value", "_scenario": "empty field", ...}
|
|
50
|
+
],
|
|
51
|
+
"security": [
|
|
52
|
+
{"field_name": "payload", "_test_type": "sql_injection", ...}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestDataGeneratorAgent:
|
|
60
|
+
"""Generate realistic test data for form fields using AI."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: AltQAConfig) -> None:
|
|
63
|
+
self.config = config
|
|
64
|
+
self.model = resolve_model(config.llm)
|
|
65
|
+
|
|
66
|
+
def generate(
|
|
67
|
+
self,
|
|
68
|
+
fields: list[dict[str, str]],
|
|
69
|
+
form_context: str = "",
|
|
70
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
71
|
+
"""Generate test data sets for a list of form fields.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
fields: List of dicts like [{"name": "email", "type": "email"}, ...]
|
|
75
|
+
form_context: Optional description of the form purpose
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict with keys: valid, invalid, boundary, security — each a list of data dicts.
|
|
79
|
+
"""
|
|
80
|
+
user_prompt = self._build_prompt(fields, form_context)
|
|
81
|
+
|
|
82
|
+
from agno.utils.log import logger as agno_logger
|
|
83
|
+
prev_handlers = agno_logger.handlers[:]
|
|
84
|
+
prev_propagate = agno_logger.propagate
|
|
85
|
+
agno_logger.handlers = [logging.NullHandler()]
|
|
86
|
+
agno_logger.propagate = False
|
|
87
|
+
try:
|
|
88
|
+
agent = Agent(
|
|
89
|
+
model=self.model,
|
|
90
|
+
instructions=[DATA_GEN_PROMPT],
|
|
91
|
+
markdown=False,
|
|
92
|
+
)
|
|
93
|
+
response = agent.run(user_prompt)
|
|
94
|
+
content = _clean(response.content or "{}")
|
|
95
|
+
return json.loads(content)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
log.warning("Test data generation failed: %s", exc)
|
|
98
|
+
return self._fallback(fields)
|
|
99
|
+
finally:
|
|
100
|
+
agno_logger.handlers = prev_handlers
|
|
101
|
+
agno_logger.propagate = prev_propagate
|
|
102
|
+
|
|
103
|
+
def generate_for_page(
|
|
104
|
+
self,
|
|
105
|
+
page_html: str,
|
|
106
|
+
page_url: str = "",
|
|
107
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
108
|
+
"""Extract form fields from HTML and generate test data."""
|
|
109
|
+
fields = self._extract_fields_from_html(page_html)
|
|
110
|
+
if not fields:
|
|
111
|
+
return {"valid": [], "invalid": [], "boundary": [], "security": []}
|
|
112
|
+
return self.generate(fields, form_context=f"Form on page: {page_url}")
|
|
113
|
+
|
|
114
|
+
def _build_prompt(self, fields: list[dict[str, str]], context: str) -> str:
|
|
115
|
+
parts = ["## Form Fields\n"]
|
|
116
|
+
if context:
|
|
117
|
+
parts.append(f"**Context:** {context}\n")
|
|
118
|
+
for f in fields:
|
|
119
|
+
parts.append(f"- **{f.get('name', 'unknown')}**: type={f.get('type', 'text')}")
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
def _extract_fields_from_html(self, html: str) -> list[dict[str, str]]:
|
|
123
|
+
"""Simple extraction of input fields from HTML."""
|
|
124
|
+
import re
|
|
125
|
+
fields: list[dict[str, str]] = []
|
|
126
|
+
for match in re.finditer(r'<input[^>]*>', html, re.IGNORECASE):
|
|
127
|
+
tag = match.group()
|
|
128
|
+
name_m = re.search(r'name=["\']([^"\']+)', tag)
|
|
129
|
+
type_m = re.search(r'type=["\']([^"\']+)', tag)
|
|
130
|
+
if name_m:
|
|
131
|
+
fields.append({
|
|
132
|
+
"name": name_m.group(1),
|
|
133
|
+
"type": type_m.group(1) if type_m else "text",
|
|
134
|
+
})
|
|
135
|
+
return fields
|
|
136
|
+
|
|
137
|
+
def _fallback(self, fields: list[dict[str, str]]) -> dict[str, list[dict[str, Any]]]:
|
|
138
|
+
"""Deterministic fallback when AI is unavailable."""
|
|
139
|
+
valid: dict[str, Any] = {}
|
|
140
|
+
invalid: dict[str, Any] = {}
|
|
141
|
+
boundary: dict[str, Any] = {}
|
|
142
|
+
security: dict[str, Any] = {}
|
|
143
|
+
|
|
144
|
+
for f in fields:
|
|
145
|
+
name = f.get("name", "field")
|
|
146
|
+
ftype = f.get("type", "text")
|
|
147
|
+
|
|
148
|
+
if ftype == "email":
|
|
149
|
+
valid[name] = "testuser@example.com"
|
|
150
|
+
invalid[name] = "not-an-email"
|
|
151
|
+
boundary[name] = ""
|
|
152
|
+
security[name] = "test@example.com' OR 1=1--"
|
|
153
|
+
elif ftype == "password":
|
|
154
|
+
valid[name] = "SecureP@ss123!"
|
|
155
|
+
invalid[name] = "123"
|
|
156
|
+
boundary[name] = ""
|
|
157
|
+
security[name] = "' OR 1=1--"
|
|
158
|
+
elif "number" in ftype or "price" in name or "stock" in name:
|
|
159
|
+
valid[name] = "100"
|
|
160
|
+
invalid[name] = "abc"
|
|
161
|
+
boundary[name] = "-1"
|
|
162
|
+
security[name] = "999999999999"
|
|
163
|
+
else:
|
|
164
|
+
valid[name] = f"Test {name.title()}"
|
|
165
|
+
invalid[name] = ""
|
|
166
|
+
boundary[name] = "A" * 256
|
|
167
|
+
security[name] = "<script>alert(1)</script>"
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"valid": [valid],
|
|
171
|
+
"invalid": [{**invalid, "_expect_error": "Validation should fail"}],
|
|
172
|
+
"boundary": [{**boundary, "_scenario": "Edge case values"}],
|
|
173
|
+
"security": [{**security, "_test_type": "injection_tests"}],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _clean(content: str) -> str:
|
|
178
|
+
content = content.strip()
|
|
179
|
+
if content.startswith("```"):
|
|
180
|
+
first_newline = content.index("\n") if "\n" in content else 3
|
|
181
|
+
content = content[first_newline + 1:]
|
|
182
|
+
if content.endswith("```"):
|
|
183
|
+
content = content[:-3]
|
|
184
|
+
return content.strip()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""FailureAnalyzerAgent — AI-powered bug report generation from test failures.
|
|
2
|
+
|
|
3
|
+
Analyses test execution results and produces structured bug reports
|
|
4
|
+
ready to be filed as Jira sub-tasks via the BugReporter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agno.agent import Agent
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from altqa.config.models import resolve_model
|
|
18
|
+
from altqa.config.schema import AltQAConfig
|
|
19
|
+
from altqa.flows.runner import FlowResult, StepResult
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("altqa.agents")
|
|
22
|
+
|
|
23
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "analyze_failure.md"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BugReport(BaseModel):
|
|
27
|
+
"""Structured bug report generated by AI analysis."""
|
|
28
|
+
summary: str = ""
|
|
29
|
+
description: str = ""
|
|
30
|
+
severity: str = "major"
|
|
31
|
+
steps_to_reproduce: list[str] = Field(default_factory=list)
|
|
32
|
+
expected_result: str = ""
|
|
33
|
+
actual_result: str = ""
|
|
34
|
+
affected_area: str = ""
|
|
35
|
+
probable_fix: str = ""
|
|
36
|
+
user_impact: str = ""
|
|
37
|
+
regression_test: str = ""
|
|
38
|
+
environment: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FailureAnalyzerAgent:
|
|
42
|
+
"""Analyse test failures and generate structured bug reports."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: AltQAConfig) -> None:
|
|
45
|
+
self.config = config
|
|
46
|
+
self.model = resolve_model(config.llm)
|
|
47
|
+
self._system_prompt = _PROMPT_PATH.read_text()
|
|
48
|
+
|
|
49
|
+
def analyze_step_failure(
|
|
50
|
+
self,
|
|
51
|
+
flow_name: str,
|
|
52
|
+
failed_step: StepResult,
|
|
53
|
+
all_steps: list[StepResult],
|
|
54
|
+
page_url: str = "",
|
|
55
|
+
) -> BugReport:
|
|
56
|
+
"""Generate a BugReport for a single failed step."""
|
|
57
|
+
user_prompt = self._build_step_prompt(flow_name, failed_step, all_steps, page_url)
|
|
58
|
+
return self._run(user_prompt, flow_name)
|
|
59
|
+
|
|
60
|
+
def analyze_flow_result(self, flow_result: FlowResult) -> list[BugReport]:
|
|
61
|
+
"""Generate BugReports for all failed steps in a FlowResult."""
|
|
62
|
+
reports: list[BugReport] = []
|
|
63
|
+
|
|
64
|
+
for failed_step in flow_result.failed_steps:
|
|
65
|
+
try:
|
|
66
|
+
report = self.analyze_step_failure(
|
|
67
|
+
flow_name=flow_result.flow_name,
|
|
68
|
+
failed_step=failed_step,
|
|
69
|
+
all_steps=flow_result.steps,
|
|
70
|
+
)
|
|
71
|
+
reports.append(report)
|
|
72
|
+
log.info("Bug report generated for step %d of %s", failed_step.step_number, flow_result.flow_name)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
log.error("Failed to analyse step %d: %s", failed_step.step_number, exc)
|
|
75
|
+
reports.append(self._fallback_report(flow_result.flow_name, failed_step))
|
|
76
|
+
|
|
77
|
+
return reports
|
|
78
|
+
|
|
79
|
+
def severity_to_priority(self, severity: str) -> str:
|
|
80
|
+
"""Map AI severity to Jira priority."""
|
|
81
|
+
mapping = {
|
|
82
|
+
"critical": "Highest",
|
|
83
|
+
"major": "High",
|
|
84
|
+
"minor": "Medium",
|
|
85
|
+
}
|
|
86
|
+
return mapping.get(severity.lower(), "Medium")
|
|
87
|
+
|
|
88
|
+
# ── Internal ───────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _run(self, user_prompt: str, context_name: str) -> BugReport:
|
|
91
|
+
agent = Agent(
|
|
92
|
+
model=self.model,
|
|
93
|
+
instructions=[self._system_prompt],
|
|
94
|
+
markdown=False,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
response = agent.run(user_prompt)
|
|
98
|
+
content = self._clean(response.content or "{}")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(content)
|
|
102
|
+
return BugReport(
|
|
103
|
+
summary=data.get("summary", ""),
|
|
104
|
+
description=data.get("description", ""),
|
|
105
|
+
severity=data.get("severity", "major"),
|
|
106
|
+
steps_to_reproduce=data.get("steps_to_reproduce", []),
|
|
107
|
+
expected_result=data.get("expected_result", ""),
|
|
108
|
+
actual_result=data.get("actual_result", ""),
|
|
109
|
+
affected_area=data.get("affected_area", ""),
|
|
110
|
+
probable_fix=data.get("probable_fix", ""),
|
|
111
|
+
user_impact=data.get("user_impact", ""),
|
|
112
|
+
regression_test=data.get("regression_test", ""),
|
|
113
|
+
environment=data.get("environment", ""),
|
|
114
|
+
)
|
|
115
|
+
except (json.JSONDecodeError, Exception) as exc:
|
|
116
|
+
log.warning("AI analysis failed for %s: %s", context_name, exc)
|
|
117
|
+
return BugReport(summary=f"Test failure in {context_name}", severity="major")
|
|
118
|
+
|
|
119
|
+
def _build_step_prompt(
|
|
120
|
+
self,
|
|
121
|
+
flow_name: str,
|
|
122
|
+
failed_step: StepResult,
|
|
123
|
+
all_steps: list[StepResult],
|
|
124
|
+
page_url: str,
|
|
125
|
+
) -> str:
|
|
126
|
+
ar = failed_step.action_result
|
|
127
|
+
|
|
128
|
+
preceding_steps = [
|
|
129
|
+
f" {s.step_number}. [{s.action}] {s.description} — {'PASS' if s.passed else 'FAIL'}"
|
|
130
|
+
for s in all_steps
|
|
131
|
+
if s.step_number <= failed_step.step_number
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
parts = [
|
|
135
|
+
f"## Test Failure Details\n",
|
|
136
|
+
f"**Flow:** {flow_name}",
|
|
137
|
+
f"**Failed Step:** #{failed_step.step_number} — {failed_step.description}",
|
|
138
|
+
f"**Action:** {failed_step.action}",
|
|
139
|
+
f"**Error:** {ar.error or 'Unknown error'}",
|
|
140
|
+
f"**Duration:** {ar.duration_ms:.0f}ms",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
if page_url:
|
|
144
|
+
parts.append(f"**Page URL:** {page_url}")
|
|
145
|
+
|
|
146
|
+
if ar.details:
|
|
147
|
+
parts.append(f"**Action details:** {json.dumps(ar.details)}")
|
|
148
|
+
|
|
149
|
+
if ar.screenshot_path:
|
|
150
|
+
parts.append(f"**Screenshot:** {ar.screenshot_path}")
|
|
151
|
+
|
|
152
|
+
parts.append(f"\n**Execution history:**")
|
|
153
|
+
parts.extend(preceding_steps)
|
|
154
|
+
|
|
155
|
+
parts.append(f"\n**App URL:** {self.config.app_url}")
|
|
156
|
+
|
|
157
|
+
return "\n".join(parts)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _fallback_report(flow_name: str, failed_step: StepResult) -> BugReport:
|
|
161
|
+
ar = failed_step.action_result
|
|
162
|
+
return BugReport(
|
|
163
|
+
summary=f"[{flow_name}] Step {failed_step.step_number} failed: {failed_step.action}",
|
|
164
|
+
description=f"Action '{failed_step.action}' failed with error: {ar.error}",
|
|
165
|
+
severity="major",
|
|
166
|
+
steps_to_reproduce=[f"Run flow '{flow_name}' and observe step {failed_step.step_number}"],
|
|
167
|
+
expected_result=f"Step {failed_step.step_number} ({failed_step.description}) should succeed",
|
|
168
|
+
actual_result=ar.error or "Step failed",
|
|
169
|
+
affected_area=flow_name,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _clean(content: str) -> str:
|
|
174
|
+
content = content.strip()
|
|
175
|
+
if content.startswith("```"):
|
|
176
|
+
first_newline = content.index("\n") if "\n" in content else 3
|
|
177
|
+
content = content[first_newline + 1:]
|
|
178
|
+
if content.endswith("```"):
|
|
179
|
+
content = content[:-3]
|
|
180
|
+
return content.strip()
|