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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
)
|