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,149 @@
|
|
|
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
|
+
"""CTRF (Common Test Results Format) report formatter for NAT scan output.
|
|
7
|
+
|
|
8
|
+
Produces a single ``ctrf-report.json`` file that conforms to the
|
|
9
|
+
`CTRF schema <https://ctrf.io>`_ (v0). The report contains one test entry
|
|
10
|
+
per test case in the provided :class:`~mannf.core.testing.models.TestSuite`.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from mannf.product.formatters.ctrf_formatter import build_ctrf_report
|
|
15
|
+
|
|
16
|
+
json_str = build_ctrf_report(report, suite)
|
|
17
|
+
Path("ctrf-report.json").write_text(json_str, encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
CTRF is consumed by several GitHub Action steps (e.g. ``ctrf-io/github-actions-ctrf``),
|
|
20
|
+
Slack notification bots, and custom dashboards.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from mannf.core.testing.models import TestSuite
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Status mapping
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# CTRF statuses: passed | failed | skipped | pending | other
|
|
37
|
+
_STATUS_MAP = {
|
|
38
|
+
"passed": "passed",
|
|
39
|
+
"failed": "failed",
|
|
40
|
+
"broken": "failed",
|
|
41
|
+
"skipped": "skipped",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _result_status(result: Any) -> str:
|
|
46
|
+
"""Return a CTRF-compatible status string for a single test result."""
|
|
47
|
+
if not getattr(result, "passed", True):
|
|
48
|
+
return "failed"
|
|
49
|
+
return "passed"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Public API
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_ctrf_report(
|
|
58
|
+
report: dict[str, Any],
|
|
59
|
+
suite: "TestSuite",
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Build a CTRF JSON report string from a NAT scan result.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
report:
|
|
66
|
+
The NAT scan report dict (as produced by ``_run_scan``).
|
|
67
|
+
suite:
|
|
68
|
+
The :class:`~mannf.core.testing.models.TestSuite` that ran the tests.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
str
|
|
73
|
+
Pretty-printed CTRF JSON suitable for writing to a ``.json`` file.
|
|
74
|
+
"""
|
|
75
|
+
s = report.get("suite", {})
|
|
76
|
+
suite_name = getattr(suite, "name", report.get("spec", "NAT Scan"))
|
|
77
|
+
base_url = report.get("base_url", report.get("endpoint", ""))
|
|
78
|
+
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
79
|
+
|
|
80
|
+
total = int(s.get("total", 0))
|
|
81
|
+
passed = int(s.get("passed", 0))
|
|
82
|
+
failed = int(s.get("failed", 0)) + int(s.get("errored", 0))
|
|
83
|
+
skipped = 0
|
|
84
|
+
pending = 0
|
|
85
|
+
other = total - passed - failed - skipped - pending
|
|
86
|
+
if other < 0:
|
|
87
|
+
other = 0
|
|
88
|
+
|
|
89
|
+
tests: list[dict[str, Any]] = []
|
|
90
|
+
for result in getattr(suite, "results", []):
|
|
91
|
+
tc_id = getattr(result, "test_case_id", None) or "unknown"
|
|
92
|
+
status = _result_status(result)
|
|
93
|
+
exec_ms = getattr(result, "execution_time_ms", None) or 0.0
|
|
94
|
+
error = getattr(result, "error", None)
|
|
95
|
+
|
|
96
|
+
entry: dict[str, Any] = {
|
|
97
|
+
"name": tc_id,
|
|
98
|
+
"status": status,
|
|
99
|
+
"duration": round(float(exec_ms), 3),
|
|
100
|
+
"start": now_ms,
|
|
101
|
+
"stop": now_ms + int(exec_ms),
|
|
102
|
+
}
|
|
103
|
+
if error:
|
|
104
|
+
entry["message"] = str(error)
|
|
105
|
+
entry["trace"] = str(error)
|
|
106
|
+
if base_url:
|
|
107
|
+
entry["suite"] = suite_name
|
|
108
|
+
tests.append(entry)
|
|
109
|
+
|
|
110
|
+
ctrf: dict[str, Any] = {
|
|
111
|
+
"results": {
|
|
112
|
+
"tool": {
|
|
113
|
+
"name": "NAT",
|
|
114
|
+
"version": _nat_version(),
|
|
115
|
+
"url": "https://nat-testing.io",
|
|
116
|
+
},
|
|
117
|
+
"summary": {
|
|
118
|
+
"tests": total,
|
|
119
|
+
"passed": passed,
|
|
120
|
+
"failed": failed,
|
|
121
|
+
"pending": pending,
|
|
122
|
+
"skipped": skipped,
|
|
123
|
+
"other": other,
|
|
124
|
+
"start": now_ms,
|
|
125
|
+
"stop": now_ms,
|
|
126
|
+
},
|
|
127
|
+
"tests": tests,
|
|
128
|
+
"environment": {
|
|
129
|
+
"appName": suite_name,
|
|
130
|
+
"appUrl": base_url,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return json.dumps(ctrf, indent=2, ensure_ascii=False)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Internal helpers
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _nat_version() -> str:
|
|
144
|
+
"""Return the installed NAT version string, or 'unknown' if unavailable."""
|
|
145
|
+
try:
|
|
146
|
+
from mannf._version import __version__
|
|
147
|
+
return __version__
|
|
148
|
+
except Exception:
|
|
149
|
+
return "unknown"
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
"""Self-healing test suite subsystem for NAT."""
|
|
7
|
+
|
|
8
|
+
from mannf.product.healing.graphql_schema_diff import GraphQLSchemaDiffer
|
|
9
|
+
from mannf.product.healing.healer import TestHealer
|
|
10
|
+
from mannf.product.healing.models import (
|
|
11
|
+
EndpointChange,
|
|
12
|
+
FieldChange,
|
|
13
|
+
HealingAction,
|
|
14
|
+
HealingReport,
|
|
15
|
+
HealingResult,
|
|
16
|
+
SchemaDiff,
|
|
17
|
+
)
|
|
18
|
+
from mannf.product.healing.schema_diff import SchemaDiffer
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SchemaDiff",
|
|
22
|
+
"EndpointChange",
|
|
23
|
+
"FieldChange",
|
|
24
|
+
"HealingAction",
|
|
25
|
+
"HealingReport",
|
|
26
|
+
"HealingResult",
|
|
27
|
+
"SchemaDiffer",
|
|
28
|
+
"GraphQLSchemaDiffer",
|
|
29
|
+
"TestHealer",
|
|
30
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
"""GraphQL schema differ."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from mannf.product.healing.models import EndpointChange, FieldChange, SchemaDiff
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GraphQLSchemaDiffer:
|
|
19
|
+
"""Compare two GraphQL introspection results and return a :class:`SchemaDiff`."""
|
|
20
|
+
|
|
21
|
+
def diff(
|
|
22
|
+
self, old_introspection: Dict[str, Any], new_introspection: Dict[str, Any]
|
|
23
|
+
) -> SchemaDiff:
|
|
24
|
+
"""Compare two GraphQL introspection results."""
|
|
25
|
+
old_schema = self._extract_schema(old_introspection)
|
|
26
|
+
new_schema = self._extract_schema(new_introspection)
|
|
27
|
+
|
|
28
|
+
old_types = old_schema.get("types", {})
|
|
29
|
+
new_types = new_schema.get("types", {})
|
|
30
|
+
old_ops = old_schema.get("operations", {})
|
|
31
|
+
new_ops = new_schema.get("operations", {})
|
|
32
|
+
|
|
33
|
+
added_endpoints: List[EndpointChange] = []
|
|
34
|
+
removed_endpoints: List[EndpointChange] = []
|
|
35
|
+
modified_endpoints: List[EndpointChange] = []
|
|
36
|
+
|
|
37
|
+
for name in set(new_ops) - set(old_ops):
|
|
38
|
+
kind = new_ops[name].get("kind", "query").upper()
|
|
39
|
+
added_endpoints.append(EndpointChange(method=kind, path=name, change_type="added"))
|
|
40
|
+
for name in set(old_ops) - set(new_ops):
|
|
41
|
+
kind = old_ops[name].get("kind", "query").upper()
|
|
42
|
+
removed_endpoints.append(EndpointChange(method=kind, path=name, change_type="removed"))
|
|
43
|
+
for name in set(old_ops) & set(new_ops):
|
|
44
|
+
old_op = old_ops[name]
|
|
45
|
+
new_op = new_ops[name]
|
|
46
|
+
changes = self._diff_gql_operation(name, old_op, new_op, old_types, new_types)
|
|
47
|
+
if changes:
|
|
48
|
+
kind = new_op.get("kind", "query").upper()
|
|
49
|
+
modified_endpoints.append(
|
|
50
|
+
EndpointChange(method=kind, path=name, change_type="modified", details=changes)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
result = SchemaDiff(
|
|
54
|
+
added_endpoints=sorted(added_endpoints, key=lambda e: e.path),
|
|
55
|
+
removed_endpoints=sorted(removed_endpoints, key=lambda e: e.path),
|
|
56
|
+
modified_endpoints=sorted(modified_endpoints, key=lambda e: e.path),
|
|
57
|
+
)
|
|
58
|
+
result.summary = {
|
|
59
|
+
"added": len(result.added_endpoints),
|
|
60
|
+
"removed": len(result.removed_endpoints),
|
|
61
|
+
"modified": len(result.modified_endpoints),
|
|
62
|
+
"total_changes": (
|
|
63
|
+
len(result.added_endpoints)
|
|
64
|
+
+ len(result.removed_endpoints)
|
|
65
|
+
+ len(result.modified_endpoints)
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def _extract_schema(self, introspection: Dict[str, Any]) -> Dict[str, Any]:
|
|
71
|
+
"""Parse introspection result into operations and types dict."""
|
|
72
|
+
data = introspection.get("data", introspection)
|
|
73
|
+
schema = data.get("__schema", {})
|
|
74
|
+
|
|
75
|
+
types_map: Dict[str, Dict[str, Any]] = {}
|
|
76
|
+
for t in schema.get("types", []):
|
|
77
|
+
name = t.get("name", "")
|
|
78
|
+
if name and not name.startswith("__"):
|
|
79
|
+
types_map[name] = t
|
|
80
|
+
|
|
81
|
+
operations: Dict[str, Dict[str, Any]] = {}
|
|
82
|
+
|
|
83
|
+
def _extract_ops_from_type(type_name: str, kind: str) -> None:
|
|
84
|
+
type_obj = types_map.get(type_name, {})
|
|
85
|
+
for f in type_obj.get("fields", []) or []:
|
|
86
|
+
field_name = f.get("name", "")
|
|
87
|
+
if field_name:
|
|
88
|
+
operations[field_name] = {
|
|
89
|
+
"kind": kind,
|
|
90
|
+
"type": f.get("type"),
|
|
91
|
+
"args": f.get("args", []),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
query_type = (schema.get("queryType") or {}).get("name")
|
|
95
|
+
mutation_type = (schema.get("mutationType") or {}).get("name")
|
|
96
|
+
if query_type:
|
|
97
|
+
_extract_ops_from_type(query_type, "query")
|
|
98
|
+
if mutation_type:
|
|
99
|
+
_extract_ops_from_type(mutation_type, "mutation")
|
|
100
|
+
|
|
101
|
+
return {"types": types_map, "operations": operations}
|
|
102
|
+
|
|
103
|
+
def _diff_gql_operation(
|
|
104
|
+
self,
|
|
105
|
+
name: str,
|
|
106
|
+
old_op: Dict[str, Any],
|
|
107
|
+
new_op: Dict[str, Any],
|
|
108
|
+
old_types: Dict[str, Any],
|
|
109
|
+
new_types: Dict[str, Any],
|
|
110
|
+
) -> List[FieldChange]:
|
|
111
|
+
changes: List[FieldChange] = []
|
|
112
|
+
|
|
113
|
+
# Compare return type
|
|
114
|
+
old_type = _type_name(old_op.get("type"))
|
|
115
|
+
new_type = _type_name(new_op.get("type"))
|
|
116
|
+
if old_type != new_type:
|
|
117
|
+
changes.append(FieldChange(
|
|
118
|
+
field_path=f"{name}.returnType",
|
|
119
|
+
change_type="type_changed",
|
|
120
|
+
old_value=old_type,
|
|
121
|
+
new_value=new_type,
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
# Compare args
|
|
125
|
+
old_args = {a["name"]: a for a in (old_op.get("args") or []) if a.get("name")}
|
|
126
|
+
new_args = {a["name"]: a for a in (new_op.get("args") or []) if a.get("name")}
|
|
127
|
+
for arg_name in set(old_args) - set(new_args):
|
|
128
|
+
changes.append(FieldChange(
|
|
129
|
+
field_path=f"{name}.args.{arg_name}",
|
|
130
|
+
change_type="removed",
|
|
131
|
+
old_value=old_args[arg_name],
|
|
132
|
+
new_value=None,
|
|
133
|
+
))
|
|
134
|
+
for arg_name in set(new_args) - set(old_args):
|
|
135
|
+
changes.append(FieldChange(
|
|
136
|
+
field_path=f"{name}.args.{arg_name}",
|
|
137
|
+
change_type="added",
|
|
138
|
+
old_value=None,
|
|
139
|
+
new_value=new_args[arg_name],
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
return changes
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _type_name(type_ref: Any) -> str:
|
|
146
|
+
"""Unwrap NON_NULL/LIST wrappers and return base type name."""
|
|
147
|
+
if not type_ref:
|
|
148
|
+
return ""
|
|
149
|
+
kind = type_ref.get("kind", "")
|
|
150
|
+
if kind in ("NON_NULL", "LIST"):
|
|
151
|
+
return _type_name(type_ref.get("ofType"))
|
|
152
|
+
return type_ref.get("name", "")
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
"""TestHealer — automated self-healing for NAT test suites."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from mannf.product.healing.models import HealingAction, HealingReport, HealingResult
|
|
16
|
+
from mannf.product.healing.schema_diff import SchemaDiffer
|
|
17
|
+
from mannf.core.testing.models import TestCase
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestHealer:
|
|
23
|
+
"""Automatically heal a NAT test suite when the API spec changes.
|
|
24
|
+
|
|
25
|
+
Steps:
|
|
26
|
+
1. Parse both specs using OpenApiParser
|
|
27
|
+
2. Run SchemaDiffer.diff() to identify changes
|
|
28
|
+
3. For removed endpoints: retire matching tests
|
|
29
|
+
4. For added endpoints: generate new tests
|
|
30
|
+
5. For modified endpoints: remove old, generate new
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def heal(
|
|
34
|
+
self,
|
|
35
|
+
old_spec_path: str,
|
|
36
|
+
new_spec_path: str,
|
|
37
|
+
existing_tests: List[TestCase],
|
|
38
|
+
base_url: str = "",
|
|
39
|
+
scan_id: Optional[str] = None,
|
|
40
|
+
) -> HealingResult:
|
|
41
|
+
"""Run healing and return a :class:`HealingResult`."""
|
|
42
|
+
from mannf.product.integrations.openapi_parser import OpenApiParser, _load_spec
|
|
43
|
+
|
|
44
|
+
scan_id = scan_id or str(uuid.uuid4())
|
|
45
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
# Load both specs
|
|
48
|
+
old_spec = _load_spec(old_spec_path)
|
|
49
|
+
new_spec = _load_spec(new_spec_path)
|
|
50
|
+
|
|
51
|
+
# Diff
|
|
52
|
+
differ = SchemaDiffer()
|
|
53
|
+
schema_diff = differ.diff(old_spec, new_spec)
|
|
54
|
+
|
|
55
|
+
actions: List[HealingAction] = []
|
|
56
|
+
removed_tests: List[TestCase] = []
|
|
57
|
+
new_tests: List[TestCase] = []
|
|
58
|
+
|
|
59
|
+
# Build sets of affected endpoints
|
|
60
|
+
removed_eps = {(ec.method, ec.path) for ec in schema_diff.removed_endpoints}
|
|
61
|
+
added_eps = {(ec.method, ec.path) for ec in schema_diff.added_endpoints}
|
|
62
|
+
modified_eps = {(ec.method, ec.path) for ec in schema_diff.modified_endpoints}
|
|
63
|
+
|
|
64
|
+
# Categorize existing tests
|
|
65
|
+
kept_tests: List[TestCase] = []
|
|
66
|
+
for tc in existing_tests:
|
|
67
|
+
spec_path = tc.metadata.get("spec_path", tc.target)
|
|
68
|
+
method = tc.metadata.get("method", "GET")
|
|
69
|
+
key = (method, spec_path)
|
|
70
|
+
|
|
71
|
+
if key in removed_eps:
|
|
72
|
+
removed_tests.append(tc)
|
|
73
|
+
actions.append(HealingAction(
|
|
74
|
+
action_type="removed",
|
|
75
|
+
endpoint=f"{method} {spec_path}",
|
|
76
|
+
test_case_id=tc.id,
|
|
77
|
+
reason="Endpoint removed from spec",
|
|
78
|
+
timestamp=now,
|
|
79
|
+
))
|
|
80
|
+
elif key in modified_eps:
|
|
81
|
+
removed_tests.append(tc)
|
|
82
|
+
actions.append(HealingAction(
|
|
83
|
+
action_type="removed",
|
|
84
|
+
endpoint=f"{method} {spec_path}",
|
|
85
|
+
test_case_id=tc.id,
|
|
86
|
+
reason="Endpoint modified in spec — regenerating",
|
|
87
|
+
timestamp=now,
|
|
88
|
+
))
|
|
89
|
+
else:
|
|
90
|
+
kept_tests.append(tc)
|
|
91
|
+
|
|
92
|
+
# Generate new tests for added endpoints and modified endpoints
|
|
93
|
+
needs_regen = added_eps | modified_eps
|
|
94
|
+
if needs_regen:
|
|
95
|
+
try:
|
|
96
|
+
parser = OpenApiParser(spec_path=new_spec_path, base_url=base_url)
|
|
97
|
+
all_new_cases = parser.parse()
|
|
98
|
+
for tc in all_new_cases:
|
|
99
|
+
tc_spec_path = tc.metadata.get("spec_path", tc.target)
|
|
100
|
+
tc_method = tc.metadata.get("method", "GET")
|
|
101
|
+
key = (tc_method, tc_spec_path)
|
|
102
|
+
if key in needs_regen:
|
|
103
|
+
new_tests.append(tc)
|
|
104
|
+
action_type = "regenerated"
|
|
105
|
+
reason = (
|
|
106
|
+
"Endpoint added to spec"
|
|
107
|
+
if key in added_eps
|
|
108
|
+
else "Endpoint modified — regenerated"
|
|
109
|
+
)
|
|
110
|
+
actions.append(HealingAction(
|
|
111
|
+
action_type=action_type,
|
|
112
|
+
endpoint=f"{tc_method} {tc_spec_path}",
|
|
113
|
+
test_case_id=tc.id,
|
|
114
|
+
reason=reason,
|
|
115
|
+
timestamp=now,
|
|
116
|
+
))
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.warning("Failed to regenerate tests for new/modified endpoints: %s", exc)
|
|
119
|
+
|
|
120
|
+
summary = {
|
|
121
|
+
"regenerated": sum(1 for a in actions if a.action_type == "regenerated"),
|
|
122
|
+
"removed": sum(1 for a in actions if a.action_type == "removed"),
|
|
123
|
+
"kept": len(kept_tests),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
report = HealingReport(
|
|
127
|
+
scan_id=scan_id,
|
|
128
|
+
old_spec=old_spec_path,
|
|
129
|
+
new_spec=new_spec_path,
|
|
130
|
+
actions=actions,
|
|
131
|
+
summary=summary,
|
|
132
|
+
timestamp=now,
|
|
133
|
+
schema_diff=schema_diff,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return HealingResult(
|
|
137
|
+
kept_tests=kept_tests,
|
|
138
|
+
removed_tests=removed_tests,
|
|
139
|
+
new_tests=new_tests,
|
|
140
|
+
healing_report=report,
|
|
141
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
"""Data models for the self-healing test suite system."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FieldChange:
|
|
17
|
+
"""A field-level change detected between two API specs."""
|
|
18
|
+
|
|
19
|
+
field_path: str
|
|
20
|
+
change_type: str # "added" | "removed" | "type_changed" | "constraint_changed"
|
|
21
|
+
old_value: Any = None
|
|
22
|
+
new_value: Any = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"field_path": self.field_path,
|
|
27
|
+
"change_type": self.change_type,
|
|
28
|
+
"old_value": self.old_value,
|
|
29
|
+
"new_value": self.new_value,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EndpointChange:
|
|
35
|
+
"""A change detected for a specific API endpoint."""
|
|
36
|
+
|
|
37
|
+
method: str
|
|
38
|
+
path: str
|
|
39
|
+
change_type: str # "added" | "removed" | "modified"
|
|
40
|
+
details: List[FieldChange] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"method": self.method,
|
|
45
|
+
"path": self.path,
|
|
46
|
+
"change_type": self.change_type,
|
|
47
|
+
"details": [d.to_dict() for d in self.details],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SchemaDiff:
|
|
53
|
+
"""Result of comparing two API specs."""
|
|
54
|
+
|
|
55
|
+
added_endpoints: List[EndpointChange] = field(default_factory=list)
|
|
56
|
+
removed_endpoints: List[EndpointChange] = field(default_factory=list)
|
|
57
|
+
modified_endpoints: List[EndpointChange] = field(default_factory=list)
|
|
58
|
+
summary: Dict[str, int] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"added_endpoints": [e.to_dict() for e in self.added_endpoints],
|
|
63
|
+
"removed_endpoints": [e.to_dict() for e in self.removed_endpoints],
|
|
64
|
+
"modified_endpoints": [e.to_dict() for e in self.modified_endpoints],
|
|
65
|
+
"summary": self.summary,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_changes(self) -> bool:
|
|
70
|
+
return bool(self.added_endpoints or self.removed_endpoints or self.modified_endpoints)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class HealingAction:
|
|
75
|
+
"""A single healing action taken by the TestHealer."""
|
|
76
|
+
|
|
77
|
+
action_type: str # "regenerated" | "removed" | "repaired"
|
|
78
|
+
endpoint: str # "{method} {path}"
|
|
79
|
+
test_case_id: str
|
|
80
|
+
reason: str
|
|
81
|
+
timestamp: str
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"action_type": self.action_type,
|
|
86
|
+
"endpoint": self.endpoint,
|
|
87
|
+
"test_case_id": self.test_case_id,
|
|
88
|
+
"reason": self.reason,
|
|
89
|
+
"timestamp": self.timestamp,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class HealingReport:
|
|
95
|
+
"""Full report of all healing actions taken during a self-healing run."""
|
|
96
|
+
|
|
97
|
+
scan_id: str
|
|
98
|
+
old_spec: str
|
|
99
|
+
new_spec: str
|
|
100
|
+
actions: List[HealingAction] = field(default_factory=list)
|
|
101
|
+
summary: Dict[str, int] = field(default_factory=dict)
|
|
102
|
+
timestamp: str = ""
|
|
103
|
+
schema_diff: Optional[SchemaDiff] = None
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"scan_id": self.scan_id,
|
|
108
|
+
"old_spec": self.old_spec,
|
|
109
|
+
"new_spec": self.new_spec,
|
|
110
|
+
"actions": [a.to_dict() for a in self.actions],
|
|
111
|
+
"summary": self.summary,
|
|
112
|
+
"timestamp": self.timestamp,
|
|
113
|
+
"schema_diff": self.schema_diff.to_dict() if self.schema_diff else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def save(self, path: str) -> None:
|
|
117
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
118
|
+
json.dump(self.to_dict(), fh, indent=2)
|
|
119
|
+
|
|
120
|
+
def human_readable(self) -> str:
|
|
121
|
+
lines = []
|
|
122
|
+
lines.append("=" * 60)
|
|
123
|
+
lines.append(" Self-Healing Report")
|
|
124
|
+
lines.append("=" * 60)
|
|
125
|
+
lines.append(f" Scan ID: {self.scan_id}")
|
|
126
|
+
lines.append(f" Old spec: {self.old_spec}")
|
|
127
|
+
lines.append(f" New spec: {self.new_spec}")
|
|
128
|
+
lines.append(f" Time: {self.timestamp}")
|
|
129
|
+
lines.append("")
|
|
130
|
+
s = self.summary
|
|
131
|
+
lines.append(f" Tests regenerated: {s.get('regenerated', 0)}")
|
|
132
|
+
lines.append(f" Tests removed: {s.get('removed', 0)}")
|
|
133
|
+
lines.append(f" Tests kept: {s.get('kept', 0)}")
|
|
134
|
+
lines.append("=" * 60)
|
|
135
|
+
if self.actions:
|
|
136
|
+
lines.append("\nActions:")
|
|
137
|
+
for action in self.actions:
|
|
138
|
+
lines.append(f" [{action.action_type.upper()}] {action.endpoint} — {action.reason}")
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
def markdown(self) -> str:
|
|
142
|
+
lines = []
|
|
143
|
+
lines.append("# Self-Healing Report")
|
|
144
|
+
lines.append(f"\n**Scan ID:** {self.scan_id}")
|
|
145
|
+
lines.append(f"**Timestamp:** {self.timestamp}")
|
|
146
|
+
lines.append(f"**Old Spec:** `{self.old_spec}`")
|
|
147
|
+
lines.append(f"**New Spec:** `{self.new_spec}`")
|
|
148
|
+
s = self.summary
|
|
149
|
+
lines.append("\n## Summary")
|
|
150
|
+
lines.append("| Action | Count |")
|
|
151
|
+
lines.append("|--------|-------|")
|
|
152
|
+
lines.append(f"| Regenerated | {s.get('regenerated', 0)} |")
|
|
153
|
+
lines.append(f"| Removed | {s.get('removed', 0)} |")
|
|
154
|
+
lines.append(f"| Kept | {s.get('kept', 0)} |")
|
|
155
|
+
if self.actions:
|
|
156
|
+
lines.append("\n## Actions")
|
|
157
|
+
lines.append("| Type | Endpoint | Reason |")
|
|
158
|
+
lines.append("|------|----------|--------|")
|
|
159
|
+
for action in self.actions:
|
|
160
|
+
lines.append(f"| {action.action_type} | {action.endpoint} | {action.reason} |")
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class HealingResult:
|
|
166
|
+
"""Result of running the TestHealer."""
|
|
167
|
+
|
|
168
|
+
kept_tests: List[Any] = field(default_factory=list) # List[TestCase]
|
|
169
|
+
removed_tests: List[Any] = field(default_factory=list) # List[TestCase]
|
|
170
|
+
new_tests: List[Any] = field(default_factory=list) # List[TestCase]
|
|
171
|
+
healing_report: Optional[HealingReport] = None
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def all_active_tests(self) -> List[Any]:
|
|
175
|
+
return self.kept_tests + self.new_tests
|