nat-engine 1__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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,967 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Gherkin/BDD Feature File ingestor plugin.
7
+
8
+ Parses Gherkin ``.feature`` files (used by Cucumber, Behave, SpecFlow,
9
+ pytest-bdd, etc.) and extracts functional test intents, mapping BDD scenarios
10
+ to API endpoints where possible.
11
+
12
+ Supported Gherkin elements
13
+ --------------------------
14
+ * **Feature** — Top-level container with name and optional description.
15
+ * **Background** — Steps that run before each scenario (setup context).
16
+ * **Scenario** — Individual test case with Given/When/Then steps.
17
+ * **Scenario Outline** — Parameterised scenario with Examples table.
18
+ * **Tags** — ``@tag`` annotations on Feature or Scenario.
19
+ * **Doc Strings** — Multi-line strings delimited by ``\"\"\"``.
20
+ * **Data Tables** — Pipe-delimited tabular data within steps.
21
+ * **And / But** — Step continuation keywords.
22
+
23
+ HTTP endpoint extraction
24
+ ------------------------
25
+ The ingestor looks for HTTP request patterns in step text:
26
+
27
+ * ``I send a (GET|POST|…) request to "/path"``
28
+ * ``the endpoint "/path"``
29
+ * Full URL patterns in step text or doc strings.
30
+
31
+ No external dependencies are required — only stdlib (``json``, ``re``,
32
+ ``urllib.parse``).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import logging
39
+ import re
40
+ from copy import deepcopy
41
+ from pathlib import Path
42
+ from typing import Any
43
+ from urllib.parse import urlparse
44
+
45
+ from mannf.product.ingestors.base import IngestorPlugin
46
+ from mannf.product.ingestors.models import (
47
+ IngestedEndpoint,
48
+ IngestedTestCase,
49
+ IngestorSource,
50
+ IngestResult,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Constants
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # Step keywords recognised as step starters
60
+ _STEP_KEYWORDS: tuple[str, ...] = (
61
+ "Given ", "When ", "Then ", "And ", "But ",
62
+ )
63
+
64
+ # Gherkin structural keywords
65
+ _BLOCK_KEYWORDS: tuple[str, ...] = (
66
+ "Feature:",
67
+ "Background:",
68
+ "Scenario Outline:",
69
+ "Scenario:",
70
+ "Examples:",
71
+ "Rule:",
72
+ )
73
+
74
+ # Regex: extract HTTP method + path from common BDD step phrases
75
+ # e.g. "I send a POST request to "/users"" or "I make a GET request to '/api/v1/items'"
76
+ _RE_HTTP_STEP = re.compile(
77
+ r"(?:send|make|submit)\s+(?:a\s+)?"
78
+ r"(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)"
79
+ r"\s+(?:request\s+)?(?:to\s+)?[\"']([^\"']+)[\"']",
80
+ re.IGNORECASE,
81
+ )
82
+
83
+ # Regex: "the endpoint "/path"" or "endpoint '/path'"
84
+ _RE_ENDPOINT_STEP = re.compile(
85
+ r"\bendpoint\s+[\"']([^\"']+)[\"']",
86
+ re.IGNORECASE,
87
+ )
88
+
89
+ # Regex: full URL (used as a fallback)
90
+ _RE_FULL_URL = re.compile(
91
+ r"https?://[^\s\"'<>]+",
92
+ re.IGNORECASE,
93
+ )
94
+
95
+ # Regex: status code in Then step (e.g. "the response status should be 201")
96
+ _RE_STATUS_CODE = re.compile(
97
+ r"\b(1\d{2}|2\d{2}|3\d{2}|4\d{2}|5\d{2})\b",
98
+ )
99
+
100
+ # Tag priority mapping
101
+ _TAG_PRIORITY: dict[str, float] = {
102
+ "critical": 1.0,
103
+ "security": 0.9,
104
+ "smoke": 0.8,
105
+ "regression": 0.7,
106
+ "sanity": 0.7,
107
+ "high": 0.8,
108
+ "medium": 0.5,
109
+ "low": 0.2,
110
+ }
111
+
112
+ _DEFAULT_PRIORITY: float = 0.5
113
+ _DEFAULT_PRIORITY_LABEL: str = "medium"
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Internal parser data structures
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ class _Step:
121
+ """A single Gherkin step."""
122
+
123
+ __slots__ = ("keyword", "text", "doc_string", "data_table")
124
+
125
+ def __init__(
126
+ self,
127
+ keyword: str,
128
+ text: str,
129
+ doc_string: str | None = None,
130
+ data_table: list[list[str]] | None = None,
131
+ ) -> None:
132
+ self.keyword = keyword
133
+ self.text = text
134
+ self.doc_string = doc_string
135
+ self.data_table = data_table
136
+
137
+
138
+ class _Scenario:
139
+ """A parsed Gherkin Scenario or Scenario Outline."""
140
+
141
+ __slots__ = ("name", "tags", "steps", "examples", "is_outline", "line_no")
142
+
143
+ def __init__(
144
+ self,
145
+ name: str,
146
+ tags: list[str],
147
+ steps: list[_Step],
148
+ examples: list[dict[str, str]],
149
+ is_outline: bool,
150
+ line_no: int,
151
+ ) -> None:
152
+ self.name = name
153
+ self.tags = tags
154
+ self.steps = steps
155
+ self.examples = examples
156
+ self.is_outline = is_outline
157
+ self.line_no = line_no
158
+
159
+
160
+ class _Feature:
161
+ """A parsed Gherkin Feature."""
162
+
163
+ __slots__ = ("name", "description", "tags", "background", "scenarios")
164
+
165
+ def __init__(
166
+ self,
167
+ name: str,
168
+ description: str,
169
+ tags: list[str],
170
+ background: list[_Step],
171
+ scenarios: list[_Scenario],
172
+ ) -> None:
173
+ self.name = name
174
+ self.description = description
175
+ self.tags = tags
176
+ self.background = background
177
+ self.scenarios = scenarios
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Lightweight Gherkin parser
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def _parse_gherkin(text: str) -> _Feature:
186
+ """Parse *text* as a Gherkin feature file.
187
+
188
+ Returns a :class:`_Feature` containing all parsed scenarios. Raises
189
+ ``ValueError`` if the text does not look like a Gherkin feature.
190
+
191
+ Parameters
192
+ ----------
193
+ text:
194
+ Raw Gherkin content.
195
+
196
+ Returns
197
+ -------
198
+ _Feature
199
+ The parsed feature.
200
+ """
201
+ lines = text.splitlines()
202
+ idx = 0
203
+ n = len(lines)
204
+
205
+ def _strip(line: str) -> str:
206
+ return line.strip()
207
+
208
+ def _is_tag_line(line: str) -> bool:
209
+ s = line.strip()
210
+ return bool(s) and s.startswith("@")
211
+
212
+ def _collect_tags(line: str) -> list[str]:
213
+ """Extract tag names (without @) from a tag line."""
214
+ return [tok.lstrip("@") for tok in line.split() if tok.startswith("@")]
215
+
216
+ def _read_doc_string() -> str:
217
+ """Consume lines until the closing \"\"\", return the content."""
218
+ nonlocal idx
219
+ # idx currently points to the opening \"\"\" line
220
+ idx += 1
221
+ doc_lines: list[str] = []
222
+ while idx < n:
223
+ stripped = _strip(lines[idx])
224
+ if stripped == '"""' or stripped == "'''":
225
+ idx += 1
226
+ break
227
+ doc_lines.append(lines[idx])
228
+ idx += 1
229
+ return "\n".join(doc_lines)
230
+
231
+ def _read_data_table() -> list[list[str]]:
232
+ """Consume pipe-delimited table lines; return list of rows."""
233
+ nonlocal idx
234
+ rows: list[list[str]] = []
235
+ while idx < n:
236
+ stripped = _strip(lines[idx])
237
+ if not stripped.startswith("|"):
238
+ break
239
+ cells = [c.strip() for c in stripped.strip("|").split("|")]
240
+ rows.append(cells)
241
+ idx += 1
242
+ return rows
243
+
244
+ def _read_steps() -> list[_Step]:
245
+ """Consume step lines until a block keyword or end of section."""
246
+ nonlocal idx
247
+ steps: list[_Step] = []
248
+ while idx < n:
249
+ stripped = _strip(lines[idx])
250
+
251
+ # A block keyword ends the step list
252
+ if any(stripped.startswith(kw) for kw in _BLOCK_KEYWORDS):
253
+ break
254
+
255
+ # Tag line also ends the step list (next scenario)
256
+ if _is_tag_line(lines[idx]):
257
+ break
258
+
259
+ # Blank line — skip
260
+ if not stripped:
261
+ idx += 1
262
+ continue
263
+
264
+ # Step line?
265
+ step_keyword: str | None = None
266
+ step_text: str = ""
267
+ for kw in _STEP_KEYWORDS:
268
+ if stripped.startswith(kw):
269
+ step_keyword = kw.rstrip()
270
+ step_text = stripped[len(kw):].strip()
271
+ break
272
+
273
+ if step_keyword is None:
274
+ # Not a step line; skip (could be continuation description)
275
+ idx += 1
276
+ continue
277
+
278
+ idx += 1
279
+ doc_string: str | None = None
280
+ data_table: list[list[str]] | None = None
281
+
282
+ # Check for doc string or data table immediately following
283
+ if idx < n:
284
+ next_stripped = _strip(lines[idx])
285
+ if next_stripped.startswith('"""') or next_stripped.startswith("'''"):
286
+ doc_string = _read_doc_string()
287
+ elif next_stripped.startswith("|"):
288
+ data_table = _read_data_table()
289
+
290
+ steps.append(_Step(step_keyword, step_text, doc_string, data_table))
291
+
292
+ return steps
293
+
294
+ def _read_examples() -> list[dict[str, str]]:
295
+ """Read an Examples table and return list of row dicts."""
296
+ nonlocal idx
297
+ # idx currently at Examples: line
298
+ idx += 1
299
+
300
+ # Skip optional description lines until first table line
301
+ while idx < n and not _strip(lines[idx]).startswith("|"):
302
+ idx += 1
303
+
304
+ rows = _read_data_table()
305
+ if not rows:
306
+ return []
307
+
308
+ headers = rows[0]
309
+ result: list[dict[str, str]] = []
310
+ for row in rows[1:]:
311
+ result.append(dict(zip(headers, row)))
312
+ return result
313
+
314
+ # ---------------------------------------------------------------
315
+ # Main parsing loop
316
+ # ---------------------------------------------------------------
317
+ feature_tags: list[str] = []
318
+ feature_name: str = ""
319
+ feature_description_lines: list[str] = []
320
+ background_steps: list[_Step] = []
321
+ scenarios: list[_Scenario] = []
322
+
323
+ # Pending tags for the next Feature/Scenario
324
+ pending_tags: list[str] = []
325
+
326
+ while idx < n:
327
+ stripped = _strip(lines[idx])
328
+
329
+ if not stripped:
330
+ idx += 1
331
+ continue
332
+
333
+ # Tag line
334
+ if _is_tag_line(lines[idx]):
335
+ pending_tags.extend(_collect_tags(stripped))
336
+ idx += 1
337
+ continue
338
+
339
+ # Feature:
340
+ if stripped.startswith("Feature:"):
341
+ feature_name = stripped[len("Feature:"):].strip()
342
+ feature_tags = pending_tags[:]
343
+ pending_tags = []
344
+ idx += 1
345
+ # Collect optional description lines until next keyword
346
+ while idx < n:
347
+ s = _strip(lines[idx])
348
+ if any(s.startswith(kw) for kw in _BLOCK_KEYWORDS) or _is_tag_line(lines[idx]):
349
+ break
350
+ if s:
351
+ feature_description_lines.append(s)
352
+ idx += 1
353
+ continue
354
+
355
+ # Background:
356
+ if stripped.startswith("Background:"):
357
+ idx += 1
358
+ background_steps = _read_steps()
359
+ continue
360
+
361
+ # Scenario Outline:
362
+ if stripped.startswith("Scenario Outline:"):
363
+ scenario_name = stripped[len("Scenario Outline:"):].strip()
364
+ scenario_tags = feature_tags + pending_tags[:]
365
+ pending_tags = []
366
+ line_no = idx + 1
367
+ idx += 1
368
+ steps = _read_steps()
369
+ # Check for Examples:
370
+ examples: list[dict[str, str]] = []
371
+ while idx < n:
372
+ s = _strip(lines[idx])
373
+ if s.startswith("Examples:"):
374
+ examples = _read_examples()
375
+ elif s.startswith("Scenario") or s.startswith("Feature") or _is_tag_line(lines[idx]):
376
+ break
377
+ elif s:
378
+ idx += 1
379
+ else:
380
+ idx += 1
381
+ scenarios.append(
382
+ _Scenario(scenario_name, scenario_tags, steps, examples, True, line_no)
383
+ )
384
+ continue
385
+
386
+ # Scenario:
387
+ if stripped.startswith("Scenario:"):
388
+ scenario_name = stripped[len("Scenario:"):].strip()
389
+ scenario_tags = feature_tags + pending_tags[:]
390
+ pending_tags = []
391
+ line_no = idx + 1
392
+ idx += 1
393
+ steps = _read_steps()
394
+ scenarios.append(
395
+ _Scenario(scenario_name, scenario_tags, steps, [], False, line_no)
396
+ )
397
+ continue
398
+
399
+ # Rule: (GWT 6 — treat like a comment, skip)
400
+ if stripped.startswith("Rule:"):
401
+ idx += 1
402
+ continue
403
+
404
+ # Anything else (orphan lines) — skip
405
+ idx += 1
406
+
407
+ return _Feature(
408
+ name=feature_name,
409
+ description=" ".join(feature_description_lines),
410
+ tags=feature_tags,
411
+ background=background_steps,
412
+ scenarios=scenarios,
413
+ )
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Endpoint & test-case extraction helpers
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ def _extract_http_from_step(step: _Step) -> tuple[str, str] | None:
422
+ """Return ``(method, path)`` from a step if an HTTP pattern is found.
423
+
424
+ Checks both the step text and any embedded doc string.
425
+
426
+ Parameters
427
+ ----------
428
+ step:
429
+ The step to analyse.
430
+
431
+ Returns
432
+ -------
433
+ tuple[str, str] | None
434
+ ``(HTTP_METHOD, path_or_url)`` or ``None``.
435
+ """
436
+ search_text = step.text
437
+ if step.doc_string:
438
+ search_text += "\n" + step.doc_string
439
+
440
+ m = _RE_HTTP_STEP.search(search_text)
441
+ if m:
442
+ return m.group(1).upper(), m.group(2)
443
+
444
+ # "the endpoint '/path'" pattern — assume GET unless method found
445
+ m2 = _RE_ENDPOINT_STEP.search(search_text)
446
+ if m2:
447
+ return "GET", m2.group(1)
448
+
449
+ return None
450
+
451
+
452
+ def _extract_full_url_from_step(step: _Step) -> str | None:
453
+ """Return the first full URL found in a step's text or doc string."""
454
+ search_text = step.text
455
+ if step.doc_string:
456
+ search_text += "\n" + step.doc_string
457
+ m = _RE_FULL_URL.search(search_text)
458
+ return m.group(0) if m else None
459
+
460
+
461
+ def _extract_base_url_from_background(
462
+ background: list[_Step],
463
+ ) -> str | None:
464
+ """Scan Background steps for a base URL."""
465
+ for step in background:
466
+ url = _extract_full_url_from_step(step)
467
+ if url:
468
+ return url
469
+ return None
470
+
471
+
472
+ def _extract_status_code_from_steps(steps: list[_Step]) -> int | None:
473
+ """Return the first HTTP status code found in Then/And steps."""
474
+ for step in steps:
475
+ kw_lower = step.keyword.lower()
476
+ if kw_lower in ("then", "and", "but"):
477
+ m = _RE_STATUS_CODE.search(step.text)
478
+ if m:
479
+ code = int(m.group(1))
480
+ if 100 <= code <= 599:
481
+ return code
482
+ return None
483
+
484
+
485
+ def _extract_request_body_from_steps(
486
+ steps: list[_Step],
487
+ ) -> dict | str | None:
488
+ """Return a parsed JSON body (or raw string) from Given/When steps with doc strings."""
489
+ for step in steps:
490
+ kw_lower = step.keyword.lower()
491
+ if kw_lower in ("given", "when", "and", "but") and step.doc_string:
492
+ raw = step.doc_string.strip()
493
+ try:
494
+ return json.loads(raw)
495
+ except (json.JSONDecodeError, ValueError):
496
+ return raw
497
+ return None
498
+
499
+
500
+ def _priority_from_tags(tags: list[str]) -> tuple[float, str]:
501
+ """Return ``(priority_float, priority_label)`` based on scenario tags.
502
+
503
+ The highest-priority tag found wins. Falls back to default (0.5 / medium)
504
+ when no recognised priority tags are present.
505
+
506
+ Parameters
507
+ ----------
508
+ tags:
509
+ List of tag strings (without ``@``).
510
+
511
+ Returns
512
+ -------
513
+ tuple[float, str]
514
+ Priority value and label.
515
+ """
516
+ best_value: float | None = None
517
+ best_label: str = _DEFAULT_PRIORITY_LABEL
518
+
519
+ for tag in tags:
520
+ tag_lower = tag.lower()
521
+ if tag_lower in _TAG_PRIORITY:
522
+ value = _TAG_PRIORITY[tag_lower]
523
+ if best_value is None or value > best_value:
524
+ best_value = value
525
+ best_label = tag_lower
526
+
527
+ return (best_value if best_value is not None else _DEFAULT_PRIORITY), best_label
528
+
529
+
530
+ def _expand_outline(
531
+ scenario: _Scenario,
532
+ ) -> list[tuple[str, list[_Step]]]:
533
+ """Expand a Scenario Outline into concrete (name, steps) variants.
534
+
535
+ Each row in the Examples table produces one variant. Placeholder tokens
536
+ ``<column>`` in step text and doc strings are substituted with the
537
+ corresponding value from the row.
538
+
539
+ Parameters
540
+ ----------
541
+ scenario:
542
+ The Scenario Outline to expand.
543
+
544
+ Returns
545
+ -------
546
+ list[tuple[str, list[_Step]]]
547
+ A list of ``(variant_name, steps)`` tuples.
548
+ """
549
+ if not scenario.examples:
550
+ # Outline with no examples — return the template as-is
551
+ return [(scenario.name, scenario.steps)]
552
+
553
+ variants: list[tuple[str, list[_Step]]] = []
554
+ for row in scenario.examples:
555
+ # Substitute placeholders in the scenario name
556
+ variant_name = scenario.name
557
+ for col, val in row.items():
558
+ variant_name = variant_name.replace(f"<{col}>", val)
559
+
560
+ # Deep-copy and substitute step text / doc strings
561
+ new_steps: list[_Step] = []
562
+ for step in scenario.steps:
563
+ new_text = step.text
564
+ new_doc = step.doc_string
565
+ new_table = deepcopy(step.data_table) if step.data_table else None
566
+ for col, val in row.items():
567
+ placeholder = f"<{col}>"
568
+ new_text = new_text.replace(placeholder, val)
569
+ if new_doc:
570
+ new_doc = new_doc.replace(placeholder, val)
571
+ if new_table:
572
+ new_table = [
573
+ [cell.replace(placeholder, val) for cell in r]
574
+ for r in new_table
575
+ ]
576
+ new_steps.append(_Step(step.keyword, new_text, new_doc, new_table))
577
+
578
+ variants.append((variant_name, new_steps))
579
+
580
+ return variants
581
+
582
+
583
+ def _normalise_path(path: str) -> str:
584
+ """Return just the path component of *path*, stripping any host/scheme."""
585
+ if path.startswith("http://") or path.startswith("https://"):
586
+ parsed = urlparse(path)
587
+ return parsed.path or "/"
588
+ return path if path.startswith("/") else f"/{path}"
589
+
590
+
591
+ def _endpoint_key(method: str, path: str) -> str:
592
+ """Return a deduplication key for an endpoint."""
593
+ return f"{method.upper()}:{_normalise_path(path)}"
594
+
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # GherkinIngestor
598
+ # ---------------------------------------------------------------------------
599
+
600
+
601
+ class GherkinIngestor(IngestorPlugin):
602
+ """Ingestor plugin for Gherkin/BDD ``.feature`` files.
603
+
604
+ Parses Gherkin feature files used by Cucumber, Behave, SpecFlow, and
605
+ pytest-bdd, and converts BDD scenarios into :class:`IngestedTestCase`
606
+ objects (and :class:`IngestedEndpoint` objects where HTTP patterns are
607
+ detectable in step text).
608
+
609
+ Configuration keys
610
+ ------------------
611
+ ``base_url`` : str, optional
612
+ Default base URL to prepend when steps contain only a path
613
+ (e.g. ``"/users"``).
614
+ ``tag_filter`` : list[str] | str, optional
615
+ Only ingest scenarios that carry at least one of these tags (without
616
+ the ``@`` prefix). When omitted or empty, all scenarios are ingested.
617
+ ``extract_endpoints`` : bool, optional (default ``True``)
618
+ If ``False``, skip HTTP endpoint extraction entirely and only produce
619
+ test case objects.
620
+ """
621
+
622
+ name = "gherkin"
623
+ display_name = "Gherkin/BDD Feature"
624
+ description = "Parses Gherkin .feature files (Cucumber, Behave, SpecFlow, pytest-bdd)."
625
+ supported_extensions = [".feature"]
626
+ supported_formats = ["gherkin", "bdd", "cucumber", "behave"]
627
+
628
+ # ------------------------------------------------------------------
629
+ # can_handle
630
+ # ------------------------------------------------------------------
631
+
632
+ def can_handle(self, source: IngestorSource) -> bool:
633
+ """Return ``True`` for Gherkin feature files.
634
+
635
+ Detection is based on:
636
+
637
+ 1. ``source.format`` being ``"gherkin"`` (or ``"bdd"`` / ``"cucumber"``
638
+ / ``"behave"``).
639
+ 2. The file extension being ``.feature``.
640
+ 3. The raw content containing both ``Feature:`` and ``Scenario:``
641
+ keywords.
642
+
643
+ Parameters
644
+ ----------
645
+ source:
646
+ The source to evaluate.
647
+
648
+ Returns
649
+ -------
650
+ bool
651
+ """
652
+ if source.format in self.supported_formats:
653
+ return True
654
+
655
+ if source.path is not None:
656
+ ext = Path(source.path).suffix.lower()
657
+ if ext == ".feature":
658
+ return True
659
+
660
+ if source.raw_content is not None:
661
+ content = source.raw_content
662
+ return "Feature:" in content and "Scenario:" in content
663
+
664
+ return False
665
+
666
+ # ------------------------------------------------------------------
667
+ # validate_config
668
+ # ------------------------------------------------------------------
669
+
670
+ def validate_config(self, config: dict) -> list[str]:
671
+ """Validate Gherkin ingestor configuration.
672
+
673
+ Parameters
674
+ ----------
675
+ config:
676
+ Configuration dict.
677
+
678
+ Returns
679
+ -------
680
+ list[str]
681
+ Human-readable error strings. Empty if valid.
682
+ """
683
+ errors: list[str] = []
684
+
685
+ if "base_url" in config:
686
+ base_url = config["base_url"]
687
+ if not isinstance(base_url, str):
688
+ errors.append("'base_url' must be a string.")
689
+ elif base_url and not (
690
+ base_url.startswith("http://") or base_url.startswith("https://")
691
+ ):
692
+ errors.append(
693
+ "'base_url' must start with 'http://' or 'https://'."
694
+ )
695
+
696
+ if "tag_filter" in config:
697
+ tf = config["tag_filter"]
698
+ if not isinstance(tf, (str, list)):
699
+ errors.append("'tag_filter' must be a string or list of strings.")
700
+ elif isinstance(tf, list):
701
+ for item in tf:
702
+ if not isinstance(item, str):
703
+ errors.append(
704
+ "'tag_filter' list items must all be strings."
705
+ )
706
+ break
707
+
708
+ if "extract_endpoints" in config:
709
+ if not isinstance(config["extract_endpoints"], bool):
710
+ errors.append("'extract_endpoints' must be a boolean.")
711
+
712
+ return errors
713
+
714
+ # ------------------------------------------------------------------
715
+ # ingest
716
+ # ------------------------------------------------------------------
717
+
718
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
719
+ """Parse a Gherkin feature file and return endpoints + test cases.
720
+
721
+ Parameters
722
+ ----------
723
+ source:
724
+ Source to ingest.
725
+ config:
726
+ Plugin configuration (see class docstring).
727
+
728
+ Returns
729
+ -------
730
+ IngestResult
731
+ """
732
+ warnings: list[str] = []
733
+
734
+ # ------ read raw content ------
735
+ try:
736
+ content = self._read_source(source)
737
+ except (FileNotFoundError, ValueError) as exc:
738
+ return self._make_error_result(source, str(exc))
739
+
740
+ if not content.strip():
741
+ return self._make_error_result(source, "Source content is empty.")
742
+
743
+ # ------ parse ------
744
+ try:
745
+ feature = _parse_gherkin(content)
746
+ except Exception as exc: # pragma: no cover — safety net
747
+ return self._make_error_result(
748
+ source, f"Gherkin parsing failed: {exc}"
749
+ )
750
+
751
+ if not feature.name:
752
+ warnings.append("No Feature: keyword found — file may not be valid Gherkin.")
753
+
754
+ # ------ config ------
755
+ base_url: str = config.get("base_url", "")
756
+ extract_endpoints: bool = bool(config.get("extract_endpoints", True))
757
+
758
+ raw_tag_filter = config.get("tag_filter", [])
759
+ if isinstance(raw_tag_filter, str):
760
+ tag_filter: set[str] = {raw_tag_filter} if raw_tag_filter else set()
761
+ else:
762
+ tag_filter = set(raw_tag_filter)
763
+
764
+ # ------ background context ------
765
+ bg_base_url = _extract_base_url_from_background(feature.background)
766
+ effective_base_url = base_url or bg_base_url or ""
767
+
768
+ # ------ process scenarios ------
769
+ endpoints: dict[str, IngestedEndpoint] = {}
770
+ test_cases: list[IngestedTestCase] = []
771
+ skipped_by_filter = 0
772
+
773
+ for scenario in feature.scenarios:
774
+ # ---- tag filtering ----
775
+ if tag_filter:
776
+ scenario_tag_set = {t.lower() for t in scenario.tags}
777
+ if not tag_filter.intersection(scenario_tag_set):
778
+ skipped_by_filter += 1
779
+ continue
780
+
781
+ # ---- expand Scenario Outline ----
782
+ if scenario.is_outline:
783
+ variants = _expand_outline(scenario)
784
+ else:
785
+ variants = [(scenario.name, scenario.steps)]
786
+
787
+ for variant_name, vsteps in variants:
788
+ all_steps = feature.background + vsteps
789
+ tc = self._build_test_case(
790
+ scenario=scenario,
791
+ variant_name=variant_name,
792
+ steps=vsteps,
793
+ all_steps=all_steps,
794
+ feature=feature,
795
+ source=source,
796
+ )
797
+ test_cases.append(tc)
798
+
799
+ if extract_endpoints:
800
+ # Look for HTTP pattern in all steps (background + variant)
801
+ for step in all_steps:
802
+ pair = _extract_http_from_step(step)
803
+ if pair is not None:
804
+ method, raw_path = pair
805
+ path = _normalise_path(raw_path)
806
+ key = _endpoint_key(method, path)
807
+ if key not in endpoints:
808
+ endpoints[key] = self._build_endpoint(
809
+ method=method,
810
+ path=path,
811
+ scenario=scenario,
812
+ steps=all_steps,
813
+ feature=feature,
814
+ base_url=effective_base_url,
815
+ )
816
+
817
+ if skipped_by_filter:
818
+ warnings.append(
819
+ f"{skipped_by_filter} scenario(s) skipped by tag_filter."
820
+ )
821
+
822
+ if not feature.scenarios:
823
+ warnings.append("No scenarios found in the feature file.")
824
+
825
+ endpoint_list = list(endpoints.values())
826
+
827
+ stats: dict[str, Any] = {
828
+ "feature_name": feature.name,
829
+ "scenarios_count": len(feature.scenarios),
830
+ "test_cases_count": len(test_cases),
831
+ "endpoints_count": len(endpoint_list),
832
+ "skipped_by_filter": skipped_by_filter,
833
+ "has_background": bool(feature.background),
834
+ "outline_count": sum(1 for s in feature.scenarios if s.is_outline),
835
+ }
836
+
837
+ return IngestResult(
838
+ success=True,
839
+ source=source,
840
+ endpoints=endpoint_list,
841
+ test_cases=test_cases,
842
+ errors=[],
843
+ warnings=warnings,
844
+ stats=stats,
845
+ )
846
+
847
+ # ------------------------------------------------------------------
848
+ # Private builders
849
+ # ------------------------------------------------------------------
850
+
851
+ def _build_endpoint(
852
+ self,
853
+ method: str,
854
+ path: str,
855
+ scenario: _Scenario,
856
+ steps: list[_Step],
857
+ feature: _Feature,
858
+ base_url: str,
859
+ ) -> IngestedEndpoint:
860
+ """Build an :class:`IngestedEndpoint` from extracted HTTP data."""
861
+ # Auth requirements: detect keywords in step text
862
+ auth_requirements: list[str] = []
863
+ for step in steps:
864
+ text_lower = step.text.lower()
865
+ if "without auth" in text_lower or "unauthenticated" in text_lower:
866
+ auth_requirements.append("none")
867
+ break
868
+ if "auth" in text_lower or "token" in text_lower or "bearer" in text_lower:
869
+ if "bearer" not in auth_requirements:
870
+ auth_requirements.append("bearer")
871
+
872
+ request_body = _extract_request_body_from_steps(steps)
873
+
874
+ # Build responses dict from status code
875
+ responses: dict[str, Any] = {}
876
+ status_code = _extract_status_code_from_steps(steps)
877
+ if status_code is not None:
878
+ responses[str(status_code)] = {"description": "Expected status"}
879
+
880
+ # Tags: strip @ and combine feature + scenario tags
881
+ tags = list({t for t in scenario.tags})
882
+
883
+ return IngestedEndpoint(
884
+ method=method,
885
+ path=path if not base_url else path, # path stays relative
886
+ summary=scenario.name,
887
+ description=f"Extracted from Gherkin scenario: {scenario.name}",
888
+ request_body={"body": request_body} if request_body is not None else None,
889
+ responses=responses,
890
+ auth_requirements=auth_requirements,
891
+ tags=tags,
892
+ metadata={
893
+ "feature": feature.name,
894
+ "base_url": base_url,
895
+ "source_line": scenario.line_no,
896
+ },
897
+ )
898
+
899
+ def _build_test_case(
900
+ self,
901
+ scenario: _Scenario,
902
+ variant_name: str,
903
+ steps: list[_Step],
904
+ all_steps: list[_Step],
905
+ feature: _Feature,
906
+ source: IngestorSource,
907
+ ) -> IngestedTestCase:
908
+ """Build an :class:`IngestedTestCase` from a scenario variant."""
909
+ priority_value, priority_label = _priority_from_tags(scenario.tags)
910
+
911
+ # Build step dicts for the test case
912
+ step_dicts: list[dict] = []
913
+ for step in steps:
914
+ sd: dict[str, Any] = {
915
+ "keyword": step.keyword,
916
+ "text": step.text,
917
+ }
918
+ if step.doc_string is not None:
919
+ sd["doc_string"] = step.doc_string
920
+ if step.data_table is not None:
921
+ sd["data_table"] = step.data_table
922
+ step_dicts.append(sd)
923
+
924
+ # Description: concatenate Given/When/Then step text
925
+ desc_parts = [
926
+ f"{s.keyword} {s.text}" for s in steps
927
+ ]
928
+ description = " | ".join(desc_parts)
929
+
930
+ # Expected result from Then steps
931
+ then_parts = [
932
+ s.text for s in steps if s.keyword.lower() in ("then", "and", "but")
933
+ ]
934
+ expected_result = "; ".join(then_parts) if then_parts else ""
935
+
936
+ # Tags: strip @
937
+ tags = [t.lstrip("@") for t in scenario.tags]
938
+
939
+ # Source reference
940
+ source_file = source.path or "<inline>"
941
+ source_ref = f"{source_file}:{scenario.line_no} — {variant_name}"
942
+
943
+ # Expected status code
944
+ status_code = _extract_status_code_from_steps(all_steps)
945
+
946
+ # Request body
947
+ request_body = _extract_request_body_from_steps(all_steps)
948
+
949
+ metadata: dict[str, Any] = {
950
+ "feature": feature.name,
951
+ "priority_value": priority_value,
952
+ }
953
+ if status_code is not None:
954
+ metadata["expected_status_code"] = status_code
955
+ if request_body is not None:
956
+ metadata["request_body"] = request_body
957
+
958
+ return IngestedTestCase(
959
+ name=variant_name,
960
+ description=description,
961
+ steps=step_dicts,
962
+ expected_result=expected_result,
963
+ priority=priority_label,
964
+ tags=tags,
965
+ source_ref=source_ref,
966
+ metadata=metadata,
967
+ )