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.
Files changed (68) hide show
  1. altqa/__init__.py +3 -0
  2. altqa/agents/__init__.py +0 -0
  3. altqa/agents/accessibility_auditor.py +125 -0
  4. altqa/agents/assertion_interpreter.py +145 -0
  5. altqa/agents/data_generator.py +184 -0
  6. altqa/agents/failure_analyzer.py +180 -0
  7. altqa/agents/flow_generator.py +147 -0
  8. altqa/agents/natural_test.py +124 -0
  9. altqa/agents/prompts/accessibility_audit.md +42 -0
  10. altqa/agents/prompts/analyze_failure.md +44 -0
  11. altqa/agents/prompts/analyze_ticket.md +28 -0
  12. altqa/agents/prompts/generate_flows.md +52 -0
  13. altqa/agents/prompts/interpret_test_case.md +53 -0
  14. altqa/agents/prompts/natural_test.md +61 -0
  15. altqa/agents/prompts/visual_review.md +38 -0
  16. altqa/agents/root_cause_analyzer.py +198 -0
  17. altqa/agents/selector_healer.py +104 -0
  18. altqa/agents/test_case_interpreter.py +232 -0
  19. altqa/agents/ticket_analyzer.py +120 -0
  20. altqa/agents/visual_reviewer.py +135 -0
  21. altqa/browser/__init__.py +0 -0
  22. altqa/browser/actions.py +277 -0
  23. altqa/browser/health.py +362 -0
  24. altqa/browser/manager.py +141 -0
  25. altqa/browser/network.py +172 -0
  26. altqa/browser/session.py +60 -0
  27. altqa/cli/__init__.py +0 -0
  28. altqa/cli/audit_cmd.py +128 -0
  29. altqa/cli/chat_cmd.py +182 -0
  30. altqa/cli/config_cmd.py +56 -0
  31. altqa/cli/crawl_cmd.py +122 -0
  32. altqa/cli/flow_cmd.py +263 -0
  33. altqa/cli/init_cmd.py +238 -0
  34. altqa/cli/jira_cmd.py +213 -0
  35. altqa/cli/main.py +224 -0
  36. altqa/cli/run_cmd.py +418 -0
  37. altqa/cli/test_cmd.py +185 -0
  38. altqa/config/__init__.py +0 -0
  39. altqa/config/manager.py +40 -0
  40. altqa/config/models.py +45 -0
  41. altqa/config/schema.py +51 -0
  42. altqa/crawler/__init__.py +0 -0
  43. altqa/crawler/crawler.py +173 -0
  44. altqa/crawler/graph.py +168 -0
  45. altqa/crawler/page_analyzer.py +142 -0
  46. altqa/flows/__init__.py +0 -0
  47. altqa/flows/assertions.py +133 -0
  48. altqa/flows/generator.py +170 -0
  49. altqa/flows/parser.py +180 -0
  50. altqa/flows/runner.py +228 -0
  51. altqa/flows/variables.py +72 -0
  52. altqa/jira/__init__.py +0 -0
  53. altqa/jira/agent.py +212 -0
  54. altqa/jira/attachments.py +76 -0
  55. altqa/jira/bug_reporter.py +254 -0
  56. altqa/jira/test_case_parser.py +195 -0
  57. altqa/jira/ticket_parser.py +100 -0
  58. altqa/pipeline/__init__.py +0 -0
  59. altqa/pipeline/orchestrator.py +353 -0
  60. altqa/pipeline/result.py +107 -0
  61. altqa/utils/__init__.py +0 -0
  62. altqa/utils/cleanup.py +25 -0
  63. altqa/utils/logger.py +53 -0
  64. altqa_cli-0.1.0.dist-info/METADATA +206 -0
  65. altqa_cli-0.1.0.dist-info/RECORD +68 -0
  66. altqa_cli-0.1.0.dist-info/WHEEL +4 -0
  67. altqa_cli-0.1.0.dist-info/entry_points.txt +2 -0
  68. altqa_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
altqa/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AltQA — AI-powered QA automation CLI."""
2
+
3
+ __version__ = "0.1.0"
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()