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,1100 @@
|
|
|
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
|
+
"""Scenario Config Generator — Phase 2 of the functional testing pipeline.
|
|
7
|
+
|
|
8
|
+
Transforms the structured :class:`~mannf.core.browser.discovery_model.DiscoveryModel`
|
|
9
|
+
produced by :class:`~mannf.core.agents.web_crawler_agent.WebCrawlerAgent` (Phase 1)
|
|
10
|
+
into actionable scenario config dicts that match the task shape expected by
|
|
11
|
+
:meth:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator.run`.
|
|
12
|
+
|
|
13
|
+
Each generated scenario looks like::
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
"url": "https://example.com/login",
|
|
17
|
+
"steps": [
|
|
18
|
+
{"action": "fill", "selector": "#email", "value": "test@example.com"},
|
|
19
|
+
{"action": "fill", "selector": "#password", "value": "TestP@ss123!"},
|
|
20
|
+
{"action": "click", "selector": "button[type=submit]"},
|
|
21
|
+
],
|
|
22
|
+
"metadata": {
|
|
23
|
+
"scenario_type": "happy_path",
|
|
24
|
+
"source_page": "/login",
|
|
25
|
+
"source_form_action": "/api/auth/login",
|
|
26
|
+
"description": "Login with valid credentials",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import datetime
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import re
|
|
37
|
+
from collections import deque
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from mannf.core.browser.discovery_model import (
|
|
41
|
+
DiscoveredField,
|
|
42
|
+
DiscoveredForm,
|
|
43
|
+
DiscoveredPage,
|
|
44
|
+
DiscoveryModel,
|
|
45
|
+
InteractiveElement,
|
|
46
|
+
PageEdge,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Constants — realistic valid values per field type
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
_TODAY = datetime.date.today().isoformat()
|
|
56
|
+
|
|
57
|
+
_VALID_VALUES: dict[str, str] = {
|
|
58
|
+
"email": "test@example.com",
|
|
59
|
+
"password": "TestP@ss123!",
|
|
60
|
+
"tel": "+1-555-0100",
|
|
61
|
+
"url": "https://example.com",
|
|
62
|
+
"number": "42",
|
|
63
|
+
"date": _TODAY,
|
|
64
|
+
"datetime-local": f"{_TODAY}T12:00",
|
|
65
|
+
"time": "12:00",
|
|
66
|
+
"color": "#ff5733",
|
|
67
|
+
"range": "50",
|
|
68
|
+
"month": _TODAY[:7],
|
|
69
|
+
"week": f"{datetime.date.today().isocalendar()[0]}-W{datetime.date.today().isocalendar()[1]:02d}",
|
|
70
|
+
"search": "test query",
|
|
71
|
+
"checkbox": "true",
|
|
72
|
+
"radio": "option1",
|
|
73
|
+
"file": "",
|
|
74
|
+
"hidden": "",
|
|
75
|
+
"textarea": "Sample text input for testing purposes.",
|
|
76
|
+
"select": "",
|
|
77
|
+
"text": "Sample text",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Heuristic label/name → value mapping (lower-cased keyword matching)
|
|
81
|
+
_LABEL_HEURISTICS: list[tuple[tuple[str, ...], str]] = [
|
|
82
|
+
(("first_name", "firstname", "fname", "first name"), "John"),
|
|
83
|
+
(("last_name", "lastname", "lname", "last name", "surname"), "Doe"),
|
|
84
|
+
(("full_name", "fullname", "name"), "John Doe"),
|
|
85
|
+
(("username", "user_name", "user name", "login"), "johndoe"),
|
|
86
|
+
(("email", "e-mail", "mail"), "test@example.com"),
|
|
87
|
+
(("password", "pass", "pwd"), "TestP@ss123!"),
|
|
88
|
+
(("confirm_password", "confirm password", "password_confirm", "retype"), "TestP@ss123!"),
|
|
89
|
+
(("phone", "tel", "telephone", "mobile", "cell"), "+1-555-0100"),
|
|
90
|
+
(("address", "street", "addr"), "123 Main St"),
|
|
91
|
+
(("city", "town"), "Springfield"),
|
|
92
|
+
(("state", "province", "region"), "IL"),
|
|
93
|
+
(("zip", "postal", "postcode", "zipcode"), "62701"),
|
|
94
|
+
(("country",), "US"),
|
|
95
|
+
(("age",), "30"),
|
|
96
|
+
(("dob", "birthdate", "birth_date", "date_of_birth"), "1990-01-15"),
|
|
97
|
+
(("company", "organization", "organisation"), "Acme Corp"),
|
|
98
|
+
(("title", "job_title", "position"), "Software Engineer"),
|
|
99
|
+
(("website", "url", "homepage"), "https://example.com"),
|
|
100
|
+
(("message", "comment", "description", "note", "bio", "about"), "This is a test message."),
|
|
101
|
+
(("subject",), "Test Subject"),
|
|
102
|
+
(("quantity", "qty", "amount", "count"), "1"),
|
|
103
|
+
(("price", "cost"), "9.99"),
|
|
104
|
+
(("search", "query", "keyword"), "test"),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# Boundary / edge-case values per field type
|
|
108
|
+
_BOUNDARY_VALUES: dict[str, list[str]] = {
|
|
109
|
+
"text": [
|
|
110
|
+
"",
|
|
111
|
+
"a" * 1001,
|
|
112
|
+
"<script>alert('xss')</script>",
|
|
113
|
+
"'; DROP TABLE users; --",
|
|
114
|
+
"\u0000\u0001\u001f",
|
|
115
|
+
"\U0001F600\U0001F4A5", # emoji
|
|
116
|
+
"\u202e\u0041\u202c", # RTL override
|
|
117
|
+
],
|
|
118
|
+
"email": [
|
|
119
|
+
"",
|
|
120
|
+
"notanemail",
|
|
121
|
+
"double@@domain.com",
|
|
122
|
+
"nodomain@",
|
|
123
|
+
"@nodomain.com",
|
|
124
|
+
"a" * 250 + "@example.com",
|
|
125
|
+
],
|
|
126
|
+
"password": [
|
|
127
|
+
"",
|
|
128
|
+
"a",
|
|
129
|
+
"a" * 1001,
|
|
130
|
+
" ",
|
|
131
|
+
"\t\n",
|
|
132
|
+
],
|
|
133
|
+
"number": [
|
|
134
|
+
"0",
|
|
135
|
+
"-1",
|
|
136
|
+
"999999999",
|
|
137
|
+
"NaN",
|
|
138
|
+
"",
|
|
139
|
+
"1.7976931348623157e+308",
|
|
140
|
+
],
|
|
141
|
+
"tel": [
|
|
142
|
+
"",
|
|
143
|
+
"abc",
|
|
144
|
+
"+" + "1" * 30,
|
|
145
|
+
"0000000000",
|
|
146
|
+
],
|
|
147
|
+
"url": [
|
|
148
|
+
"",
|
|
149
|
+
"not-a-url",
|
|
150
|
+
"javascript:alert(1)",
|
|
151
|
+
"http://",
|
|
152
|
+
"ftp://example.com/../../../etc/passwd",
|
|
153
|
+
],
|
|
154
|
+
"date": [
|
|
155
|
+
"",
|
|
156
|
+
"9999-12-31",
|
|
157
|
+
"0000-01-01",
|
|
158
|
+
"not-a-date",
|
|
159
|
+
"2000-13-01",
|
|
160
|
+
"2000-01-32",
|
|
161
|
+
],
|
|
162
|
+
"textarea": [
|
|
163
|
+
"",
|
|
164
|
+
"a" * 10001,
|
|
165
|
+
"<script>alert('xss')</script>",
|
|
166
|
+
"'; DROP TABLE users; --",
|
|
167
|
+
"\u202e\u0041\u202c",
|
|
168
|
+
],
|
|
169
|
+
"select": ["", "__invalid_option__"],
|
|
170
|
+
"checkbox": ["false"],
|
|
171
|
+
"search": ["", "<script>alert(1)</script>", "' OR '1'='1"],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ScenarioConfigGenerator:
|
|
176
|
+
"""Generate functional test scenario configs from a :class:`DiscoveryModel`.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
discovery_model:
|
|
181
|
+
The structured page graph produced by :class:`WebCrawlerAgent`.
|
|
182
|
+
adaptive_controller:
|
|
183
|
+
Optional :class:`~mannf.core.prioritization.adaptive_controller.AdaptiveController`
|
|
184
|
+
instance for belief-guided strategy selection.
|
|
185
|
+
llm_provider:
|
|
186
|
+
Optional LLM provider (any object with an async ``generate`` method).
|
|
187
|
+
When set, LLM prompts are used for richer test data; when ``None`` the
|
|
188
|
+
generator falls back to heuristic value generation.
|
|
189
|
+
max_scenarios_per_form:
|
|
190
|
+
Upper bound on scenarios produced for any single form.
|
|
191
|
+
max_flow_depth:
|
|
192
|
+
Maximum number of pages in a generated multi-step flow.
|
|
193
|
+
include_negative:
|
|
194
|
+
Whether to include negative / invalid-input scenarios.
|
|
195
|
+
include_boundary:
|
|
196
|
+
Whether to include boundary-value scenarios.
|
|
197
|
+
include_exploratory:
|
|
198
|
+
Whether to include click-through scenarios for interactive elements.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
discovery_model: DiscoveryModel,
|
|
204
|
+
*,
|
|
205
|
+
adaptive_controller: Any | None = None,
|
|
206
|
+
llm_provider: Any | None = None,
|
|
207
|
+
max_scenarios_per_form: int = 10,
|
|
208
|
+
max_flow_depth: int = 5,
|
|
209
|
+
include_negative: bool = True,
|
|
210
|
+
include_boundary: bool = True,
|
|
211
|
+
include_exploratory: bool = True,
|
|
212
|
+
include_security: bool = False,
|
|
213
|
+
locale: str = "en-US",
|
|
214
|
+
domain_context: str = "",
|
|
215
|
+
) -> None:
|
|
216
|
+
self._model = discovery_model
|
|
217
|
+
self._ac = adaptive_controller
|
|
218
|
+
self._llm = llm_provider
|
|
219
|
+
self._max_per_form = max_scenarios_per_form
|
|
220
|
+
self._max_flow_depth = max_flow_depth
|
|
221
|
+
self._include_negative = include_negative
|
|
222
|
+
self._include_boundary = include_boundary
|
|
223
|
+
self._include_exploratory = include_exploratory
|
|
224
|
+
self._include_security = include_security
|
|
225
|
+
self._locale = locale
|
|
226
|
+
self._domain_context = domain_context
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Public API
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def generate_all(self) -> list[dict[str, Any]]:
|
|
233
|
+
"""Generate all scenario configs from the discovery model.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
list[dict]
|
|
238
|
+
A list of task dicts compatible with
|
|
239
|
+
:meth:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator.run`.
|
|
240
|
+
"""
|
|
241
|
+
scenarios: list[dict[str, Any]] = []
|
|
242
|
+
|
|
243
|
+
for page in self._model.pages:
|
|
244
|
+
for form in page.forms:
|
|
245
|
+
scenarios.extend(self._generate_form_scenarios(page, form))
|
|
246
|
+
if self._include_exploratory:
|
|
247
|
+
scenarios.extend(self._generate_interactive_element_scenarios(page))
|
|
248
|
+
|
|
249
|
+
scenarios.extend(self._generate_flow_scenarios())
|
|
250
|
+
|
|
251
|
+
if self._include_security:
|
|
252
|
+
from mannf.core.browser.security_scenario_generator import SecurityScenarioGenerator # noqa: PLC0415
|
|
253
|
+
sec_gen = SecurityScenarioGenerator(self._model)
|
|
254
|
+
scenarios.extend(sec_gen.generate())
|
|
255
|
+
|
|
256
|
+
return self._rank_by_adaptive_controller(scenarios)
|
|
257
|
+
|
|
258
|
+
async def generate_all_async(self) -> list[dict[str, Any]]:
|
|
259
|
+
"""Generate all scenario configs, optionally augmented with LLM suggestions.
|
|
260
|
+
|
|
261
|
+
Generates heuristic scenarios first via :meth:`generate_all`, then—when
|
|
262
|
+
an LLM provider is configured and available—appends additional scenarios
|
|
263
|
+
produced by the three UI-focused LLM prompts:
|
|
264
|
+
|
|
265
|
+
* :meth:`_llm_form_fill` — realistic happy-path field values per form.
|
|
266
|
+
* :meth:`_llm_negative_path` — negative / edge-case field values per form.
|
|
267
|
+
* :meth:`_llm_flow_suggestions` — AI-suggested multi-step user journeys.
|
|
268
|
+
|
|
269
|
+
LLM calls are best-effort: any failure is logged and the heuristic
|
|
270
|
+
scenarios are returned unchanged.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
list[dict]
|
|
275
|
+
Heuristic scenarios followed by LLM-generated scenarios (if any).
|
|
276
|
+
"""
|
|
277
|
+
scenarios: list[dict[str, Any]] = self.generate_all()
|
|
278
|
+
|
|
279
|
+
if self._llm is None or not self._llm.is_available():
|
|
280
|
+
return scenarios
|
|
281
|
+
|
|
282
|
+
llm_scenarios: list[dict[str, Any]] = []
|
|
283
|
+
|
|
284
|
+
for page in self._model.pages:
|
|
285
|
+
for form in page.forms:
|
|
286
|
+
# Happy-path LLM fill
|
|
287
|
+
try:
|
|
288
|
+
fill_results = await self._llm_form_fill(page, form)
|
|
289
|
+
if fill_results:
|
|
290
|
+
llm_scenarios.extend(
|
|
291
|
+
self._convert_llm_form_fill_to_scenarios(
|
|
292
|
+
page, form, fill_results
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
except Exception as exc: # noqa: BLE001
|
|
296
|
+
logger.warning("LLM form-fill conversion failed for %s: %s", page.url, exc)
|
|
297
|
+
|
|
298
|
+
# Negative-path LLM scenarios
|
|
299
|
+
try:
|
|
300
|
+
neg_results = await self._llm_negative_path(page, form)
|
|
301
|
+
if neg_results:
|
|
302
|
+
llm_scenarios.extend(
|
|
303
|
+
self._convert_llm_negative_to_scenarios(
|
|
304
|
+
page, form, neg_results
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
except Exception as exc: # noqa: BLE001
|
|
308
|
+
logger.warning(
|
|
309
|
+
"LLM negative-path conversion failed for %s: %s", page.url, exc
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Flow suggestions (once per model)
|
|
313
|
+
try:
|
|
314
|
+
flow_results = await self._llm_flow_suggestions()
|
|
315
|
+
if flow_results:
|
|
316
|
+
llm_scenarios.extend(
|
|
317
|
+
self._convert_llm_flow_to_scenarios(flow_results)
|
|
318
|
+
)
|
|
319
|
+
except Exception as exc: # noqa: BLE001
|
|
320
|
+
logger.warning("LLM flow-suggestion conversion failed: %s", exc)
|
|
321
|
+
|
|
322
|
+
# Security scenarios (LLM-augmented when include_security is set)
|
|
323
|
+
if self._include_security:
|
|
324
|
+
from mannf.core.browser.security_scenario_generator import SecurityScenarioGenerator # noqa: PLC0415
|
|
325
|
+
sec_gen = SecurityScenarioGenerator(self._model, llm_provider=self._llm)
|
|
326
|
+
try:
|
|
327
|
+
sec_scenarios = await sec_gen.generate_async()
|
|
328
|
+
llm_scenarios.extend(sec_scenarios)
|
|
329
|
+
except Exception as exc: # noqa: BLE001
|
|
330
|
+
logger.warning("Security scenario generation failed: %s", exc)
|
|
331
|
+
|
|
332
|
+
return self._rank_by_adaptive_controller(scenarios + llm_scenarios)
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
# Form scenarios
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _generate_form_scenarios(
|
|
339
|
+
self, page: DiscoveredPage, form: DiscoveredForm
|
|
340
|
+
) -> list[dict[str, Any]]:
|
|
341
|
+
"""Generate scenario configs for a single form on a page.
|
|
342
|
+
|
|
343
|
+
Produces (up to ``max_scenarios_per_form``):
|
|
344
|
+
|
|
345
|
+
1. Happy path — all fields filled with valid data.
|
|
346
|
+
2. Required-field omission — one variant per required field that skips it.
|
|
347
|
+
3. Boundary values — edge-case values per field.
|
|
348
|
+
4. Negative values — invalid format values per field.
|
|
349
|
+
5. Empty submit — submit the form without filling anything.
|
|
350
|
+
"""
|
|
351
|
+
scenarios: list[dict[str, Any]] = []
|
|
352
|
+
fillable = [f for f in form.fields if f.type not in ("hidden", "file")]
|
|
353
|
+
|
|
354
|
+
# 1. Happy path
|
|
355
|
+
scenarios.append(self._happy_path_scenario(page, form, fillable))
|
|
356
|
+
|
|
357
|
+
# 2. Required field omission
|
|
358
|
+
required_fields = [f for f in fillable if f.required]
|
|
359
|
+
for field in required_fields:
|
|
360
|
+
if len(scenarios) >= self._max_per_form:
|
|
361
|
+
break
|
|
362
|
+
scenarios.append(self._omit_field_scenario(page, form, fillable, field))
|
|
363
|
+
|
|
364
|
+
# 3. Boundary values
|
|
365
|
+
if self._include_boundary:
|
|
366
|
+
for field in fillable:
|
|
367
|
+
if len(scenarios) >= self._max_per_form:
|
|
368
|
+
break
|
|
369
|
+
boundary_vals = self._generate_boundary_value(field)
|
|
370
|
+
for bval in boundary_vals[:2]: # cap at 2 per field
|
|
371
|
+
if len(scenarios) >= self._max_per_form:
|
|
372
|
+
break
|
|
373
|
+
scenarios.append(
|
|
374
|
+
self._boundary_scenario(page, form, fillable, field, bval)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# 4. Negative values
|
|
378
|
+
if self._include_negative:
|
|
379
|
+
for field in fillable:
|
|
380
|
+
if len(scenarios) >= self._max_per_form:
|
|
381
|
+
break
|
|
382
|
+
neg_vals = self._generate_negative_value(field)
|
|
383
|
+
if neg_vals:
|
|
384
|
+
scenarios.append(
|
|
385
|
+
self._negative_scenario(page, form, fillable, field, neg_vals[0])
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# 5. Empty submit
|
|
389
|
+
if len(scenarios) < self._max_per_form:
|
|
390
|
+
scenarios.append(self._empty_submit_scenario(page, form))
|
|
391
|
+
|
|
392
|
+
return scenarios[: self._max_per_form]
|
|
393
|
+
|
|
394
|
+
def _happy_path_scenario(
|
|
395
|
+
self,
|
|
396
|
+
page: DiscoveredPage,
|
|
397
|
+
form: DiscoveredForm,
|
|
398
|
+
fillable: list[DiscoveredField],
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
steps = self._navigate_step(page.url)
|
|
401
|
+
for field in fillable:
|
|
402
|
+
value = self._generate_valid_value(field)
|
|
403
|
+
if value:
|
|
404
|
+
steps.append({"action": "fill", "selector": field.selector, "value": value})
|
|
405
|
+
steps.append({"action": "click", "selector": form.submit_selector})
|
|
406
|
+
return self._make_scenario(
|
|
407
|
+
page=page,
|
|
408
|
+
form=form,
|
|
409
|
+
steps=steps,
|
|
410
|
+
scenario_type="happy_path",
|
|
411
|
+
description=f"Happy path — fill {form.action or page.url} with valid data",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _omit_field_scenario(
|
|
415
|
+
self,
|
|
416
|
+
page: DiscoveredPage,
|
|
417
|
+
form: DiscoveredForm,
|
|
418
|
+
fillable: list[DiscoveredField],
|
|
419
|
+
omitted: DiscoveredField,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
steps = self._navigate_step(page.url)
|
|
422
|
+
for field in fillable:
|
|
423
|
+
if field.selector == omitted.selector:
|
|
424
|
+
continue
|
|
425
|
+
value = self._generate_valid_value(field)
|
|
426
|
+
if value:
|
|
427
|
+
steps.append({"action": "fill", "selector": field.selector, "value": value})
|
|
428
|
+
steps.append({"action": "click", "selector": form.submit_selector})
|
|
429
|
+
label_hint = omitted.label or omitted.name or omitted.selector
|
|
430
|
+
return self._make_scenario(
|
|
431
|
+
page=page,
|
|
432
|
+
form=form,
|
|
433
|
+
steps=steps,
|
|
434
|
+
scenario_type="negative",
|
|
435
|
+
description=f"Required field omission — skip '{label_hint}'",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def _boundary_scenario(
|
|
439
|
+
self,
|
|
440
|
+
page: DiscoveredPage,
|
|
441
|
+
form: DiscoveredForm,
|
|
442
|
+
fillable: list[DiscoveredField],
|
|
443
|
+
target_field: DiscoveredField,
|
|
444
|
+
boundary_value: str,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
steps = self._navigate_step(page.url)
|
|
447
|
+
for field in fillable:
|
|
448
|
+
if field.selector == target_field.selector:
|
|
449
|
+
value = boundary_value
|
|
450
|
+
else:
|
|
451
|
+
value = self._generate_valid_value(field)
|
|
452
|
+
if value:
|
|
453
|
+
steps.append({"action": "fill", "selector": field.selector, "value": value})
|
|
454
|
+
steps.append({"action": "click", "selector": form.submit_selector})
|
|
455
|
+
label_hint = target_field.label or target_field.name or target_field.selector
|
|
456
|
+
return self._make_scenario(
|
|
457
|
+
page=page,
|
|
458
|
+
form=form,
|
|
459
|
+
steps=steps,
|
|
460
|
+
scenario_type="boundary",
|
|
461
|
+
description=(
|
|
462
|
+
f"Boundary value for '{label_hint}': {boundary_value!r}"
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _negative_scenario(
|
|
467
|
+
self,
|
|
468
|
+
page: DiscoveredPage,
|
|
469
|
+
form: DiscoveredForm,
|
|
470
|
+
fillable: list[DiscoveredField],
|
|
471
|
+
target_field: DiscoveredField,
|
|
472
|
+
negative_value: str,
|
|
473
|
+
) -> dict[str, Any]:
|
|
474
|
+
steps = self._navigate_step(page.url)
|
|
475
|
+
for field in fillable:
|
|
476
|
+
if field.selector == target_field.selector:
|
|
477
|
+
value = negative_value
|
|
478
|
+
else:
|
|
479
|
+
value = self._generate_valid_value(field)
|
|
480
|
+
if value:
|
|
481
|
+
steps.append({"action": "fill", "selector": field.selector, "value": value})
|
|
482
|
+
steps.append({"action": "click", "selector": form.submit_selector})
|
|
483
|
+
label_hint = target_field.label or target_field.name or target_field.selector
|
|
484
|
+
return self._make_scenario(
|
|
485
|
+
page=page,
|
|
486
|
+
form=form,
|
|
487
|
+
steps=steps,
|
|
488
|
+
scenario_type="negative",
|
|
489
|
+
description=f"Negative value for '{label_hint}': {negative_value!r}",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def _empty_submit_scenario(
|
|
493
|
+
self, page: DiscoveredPage, form: DiscoveredForm
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
steps = self._navigate_step(page.url)
|
|
496
|
+
steps.append({"action": "click", "selector": form.submit_selector})
|
|
497
|
+
return self._make_scenario(
|
|
498
|
+
page=page,
|
|
499
|
+
form=form,
|
|
500
|
+
steps=steps,
|
|
501
|
+
scenario_type="negative",
|
|
502
|
+
description="Submit empty form without filling any fields",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# ------------------------------------------------------------------
|
|
506
|
+
# Flow scenarios
|
|
507
|
+
# ------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def _generate_flow_scenarios(self) -> list[dict[str, Any]]:
|
|
510
|
+
"""Generate multi-step flow scenarios by traversing the page graph.
|
|
511
|
+
|
|
512
|
+
Uses BFS from each discovered page to find paths up to
|
|
513
|
+
``max_flow_depth`` hops. Detects login-like forms (those with a
|
|
514
|
+
password field) and chains them with subsequent pages.
|
|
515
|
+
"""
|
|
516
|
+
if not self._model.edges:
|
|
517
|
+
return []
|
|
518
|
+
|
|
519
|
+
# Build adjacency list: from_url → list[(to_url, edge)]
|
|
520
|
+
adjacency: dict[str, list[tuple[str, PageEdge]]] = {}
|
|
521
|
+
for edge in self._model.edges:
|
|
522
|
+
adjacency.setdefault(edge.from_url, []).append((edge.to_url, edge))
|
|
523
|
+
|
|
524
|
+
# Index pages by URL for quick lookup
|
|
525
|
+
page_by_url: dict[str, DiscoveredPage] = {p.url: p for p in self._model.pages}
|
|
526
|
+
|
|
527
|
+
# Identify entry pages (pages that have no incoming edges or are the first page)
|
|
528
|
+
destinations = {e.to_url for e in self._model.edges}
|
|
529
|
+
entry_pages = [
|
|
530
|
+
p.url
|
|
531
|
+
for p in self._model.pages
|
|
532
|
+
if p.url not in destinations or p.url == self._model.base_url
|
|
533
|
+
]
|
|
534
|
+
if not entry_pages:
|
|
535
|
+
entry_pages = [self._model.pages[0].url] if self._model.pages else []
|
|
536
|
+
|
|
537
|
+
scenarios: list[dict[str, Any]] = []
|
|
538
|
+
|
|
539
|
+
for entry in entry_pages:
|
|
540
|
+
paths = self._bfs_paths(entry, adjacency, self._max_flow_depth)
|
|
541
|
+
for path in paths:
|
|
542
|
+
if len(path) < 2:
|
|
543
|
+
continue
|
|
544
|
+
flow_steps: list[dict[str, Any]] = []
|
|
545
|
+
for url in path:
|
|
546
|
+
page = page_by_url.get(url)
|
|
547
|
+
if page is None:
|
|
548
|
+
flow_steps.append({"action": "navigate", "selector": url})
|
|
549
|
+
continue
|
|
550
|
+
flow_steps.extend(self._navigate_step(url))
|
|
551
|
+
# Fill any login-like (password) form on this page
|
|
552
|
+
for form in page.forms:
|
|
553
|
+
fillable = [
|
|
554
|
+
f for f in form.fields if f.type not in ("hidden", "file")
|
|
555
|
+
]
|
|
556
|
+
has_password = any(f.type == "password" for f in fillable)
|
|
557
|
+
if has_password or url == path[0]:
|
|
558
|
+
for field in fillable:
|
|
559
|
+
value = self._generate_valid_value(field)
|
|
560
|
+
if value:
|
|
561
|
+
flow_steps.append(
|
|
562
|
+
{
|
|
563
|
+
"action": "fill",
|
|
564
|
+
"selector": field.selector,
|
|
565
|
+
"value": value,
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
flow_steps.append(
|
|
569
|
+
{"action": "click", "selector": form.submit_selector}
|
|
570
|
+
)
|
|
571
|
+
break # only interact with the first form per page in a flow
|
|
572
|
+
|
|
573
|
+
if flow_steps:
|
|
574
|
+
page_titles = [
|
|
575
|
+
(page_by_url[u].title if u in page_by_url else u) for u in path
|
|
576
|
+
]
|
|
577
|
+
scenarios.append(
|
|
578
|
+
{
|
|
579
|
+
"url": self._model.base_url,
|
|
580
|
+
"steps": flow_steps,
|
|
581
|
+
"metadata": {
|
|
582
|
+
"scenario_type": "flow",
|
|
583
|
+
"source_page": entry,
|
|
584
|
+
"source_form_action": "",
|
|
585
|
+
"description": (
|
|
586
|
+
f"Multi-step flow: {' → '.join(page_titles)}"
|
|
587
|
+
),
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
return scenarios
|
|
593
|
+
|
|
594
|
+
def _bfs_paths(
|
|
595
|
+
self,
|
|
596
|
+
start: str,
|
|
597
|
+
adjacency: dict[str, list[tuple[str, PageEdge]]],
|
|
598
|
+
max_depth: int,
|
|
599
|
+
) -> list[list[str]]:
|
|
600
|
+
"""Return all simple paths reachable from *start* via BFS."""
|
|
601
|
+
paths: list[list[str]] = []
|
|
602
|
+
queue: deque[list[str]] = deque([[start]])
|
|
603
|
+
while queue:
|
|
604
|
+
path = queue.popleft()
|
|
605
|
+
current = path[-1]
|
|
606
|
+
neighbours = adjacency.get(current, [])
|
|
607
|
+
for neighbour_url, _ in neighbours:
|
|
608
|
+
if neighbour_url in path:
|
|
609
|
+
continue # avoid cycles
|
|
610
|
+
new_path = path + [neighbour_url]
|
|
611
|
+
paths.append(new_path)
|
|
612
|
+
if len(new_path) < max_depth:
|
|
613
|
+
queue.append(new_path)
|
|
614
|
+
return paths
|
|
615
|
+
|
|
616
|
+
# ------------------------------------------------------------------
|
|
617
|
+
# Interactive element scenarios
|
|
618
|
+
# ------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
def _generate_interactive_element_scenarios(
|
|
621
|
+
self, page: DiscoveredPage
|
|
622
|
+
) -> list[dict[str, Any]]:
|
|
623
|
+
"""Generate click-through scenarios for non-form interactive elements."""
|
|
624
|
+
scenarios: list[dict[str, Any]] = []
|
|
625
|
+
for element in page.interactive_elements:
|
|
626
|
+
if element.type in ("button", "link"):
|
|
627
|
+
steps = self._navigate_step(page.url)
|
|
628
|
+
steps.append({"action": "click", "selector": element.selector})
|
|
629
|
+
scenarios.append(
|
|
630
|
+
{
|
|
631
|
+
"url": page.url,
|
|
632
|
+
"steps": steps,
|
|
633
|
+
"metadata": {
|
|
634
|
+
"scenario_type": "exploratory",
|
|
635
|
+
"source_page": page.url,
|
|
636
|
+
"source_form_action": "",
|
|
637
|
+
"description": (
|
|
638
|
+
f"Click interactive element: {element.text or element.selector}"
|
|
639
|
+
),
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
return scenarios
|
|
644
|
+
|
|
645
|
+
# ------------------------------------------------------------------
|
|
646
|
+
# Value generation helpers
|
|
647
|
+
# ------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
def _generate_valid_value(self, field: DiscoveredField) -> str:
|
|
650
|
+
"""Generate a realistic valid value for *field*.
|
|
651
|
+
|
|
652
|
+
Priority order:
|
|
653
|
+
1. Heuristic match on label/name/placeholder.
|
|
654
|
+
2. Type-specific default value from ``_VALID_VALUES``.
|
|
655
|
+
3. Generic ``"test"`` fallback.
|
|
656
|
+
"""
|
|
657
|
+
heuristic = self._infer_field_value_from_label(field)
|
|
658
|
+
if heuristic:
|
|
659
|
+
return heuristic
|
|
660
|
+
return _VALID_VALUES.get(field.type, "test")
|
|
661
|
+
|
|
662
|
+
def _generate_boundary_value(self, field: DiscoveredField) -> list[str]:
|
|
663
|
+
"""Generate boundary / edge-case values for *field*.
|
|
664
|
+
|
|
665
|
+
Returns a list of strings (may be empty for types without known boundaries).
|
|
666
|
+
"""
|
|
667
|
+
ftype = field.type
|
|
668
|
+
# Map some types to their common boundary category
|
|
669
|
+
if ftype in ("textarea",):
|
|
670
|
+
return list(_BOUNDARY_VALUES.get("textarea", []))
|
|
671
|
+
if ftype in ("text", "search") or ftype not in _BOUNDARY_VALUES:
|
|
672
|
+
return list(_BOUNDARY_VALUES.get("text", []))
|
|
673
|
+
return list(_BOUNDARY_VALUES.get(ftype, []))
|
|
674
|
+
|
|
675
|
+
def _generate_negative_value(self, field: DiscoveredField) -> list[str]:
|
|
676
|
+
"""Generate invalid-format values for *field* based on its type."""
|
|
677
|
+
ftype = field.type
|
|
678
|
+
if ftype == "email":
|
|
679
|
+
return ["notanemail", "double@@host.com", "@nodomain"]
|
|
680
|
+
if ftype == "password":
|
|
681
|
+
return ["", "a", " "]
|
|
682
|
+
if ftype == "url":
|
|
683
|
+
return ["not-a-url", "javascript:alert(1)", ""]
|
|
684
|
+
if ftype == "number":
|
|
685
|
+
return ["abc", "NaN", "--1"]
|
|
686
|
+
if ftype == "tel":
|
|
687
|
+
return ["abc", "000", ""]
|
|
688
|
+
if ftype == "date":
|
|
689
|
+
return ["not-a-date", "9999-99-99", ""]
|
|
690
|
+
if ftype in ("text", "textarea", "search"):
|
|
691
|
+
return [
|
|
692
|
+
"<script>alert('xss')</script>",
|
|
693
|
+
"'; DROP TABLE users; --",
|
|
694
|
+
"\u202e",
|
|
695
|
+
]
|
|
696
|
+
if ftype == "checkbox":
|
|
697
|
+
return ["notabool"]
|
|
698
|
+
return []
|
|
699
|
+
|
|
700
|
+
def _infer_field_value_from_label(self, field: DiscoveredField) -> str:
|
|
701
|
+
"""Use label/name/placeholder heuristics to infer a contextual value."""
|
|
702
|
+
# Build a candidate string from available text attributes and split into tokens
|
|
703
|
+
raw = " ".join(
|
|
704
|
+
filter(
|
|
705
|
+
None,
|
|
706
|
+
[
|
|
707
|
+
field.label,
|
|
708
|
+
field.name,
|
|
709
|
+
field.placeholder,
|
|
710
|
+
],
|
|
711
|
+
)
|
|
712
|
+
).lower()
|
|
713
|
+
candidate = re.sub(r"[\s_\-]+", "_", raw)
|
|
714
|
+
# Token set for whole-word matching (underscore-delimited)
|
|
715
|
+
tokens = set(candidate.split("_")) if candidate else set()
|
|
716
|
+
|
|
717
|
+
for keywords, value in _LABEL_HEURISTICS:
|
|
718
|
+
for kw in keywords:
|
|
719
|
+
normalised_kw = re.sub(r"[\s_\-]+", "_", kw.lower())
|
|
720
|
+
kw_tokens = set(normalised_kw.split("_"))
|
|
721
|
+
# Match if the keyword tokens are a subset of the candidate tokens,
|
|
722
|
+
# or if the full normalised keyword is an exact substring of the candidate
|
|
723
|
+
# when the keyword is a single token (avoids "name" matching "username").
|
|
724
|
+
if len(kw_tokens) == 1:
|
|
725
|
+
# Single-token keyword: require exact token match
|
|
726
|
+
if kw_tokens <= tokens:
|
|
727
|
+
return value
|
|
728
|
+
else:
|
|
729
|
+
# Multi-token keyword: require all tokens to be present
|
|
730
|
+
if kw_tokens <= tokens:
|
|
731
|
+
return value
|
|
732
|
+
|
|
733
|
+
# Special case: if the field type already implies an email address
|
|
734
|
+
if field.type == "email":
|
|
735
|
+
return "test@example.com"
|
|
736
|
+
if field.type == "password":
|
|
737
|
+
return "TestP@ss123!"
|
|
738
|
+
|
|
739
|
+
return ""
|
|
740
|
+
|
|
741
|
+
# ------------------------------------------------------------------
|
|
742
|
+
# LLM integration (optional)
|
|
743
|
+
# ------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
async def _llm_form_fill(
|
|
746
|
+
self, page: DiscoveredPage, form: DiscoveredForm, n: int = 1
|
|
747
|
+
) -> list[dict[str, Any]] | None:
|
|
748
|
+
"""Call LLM for realistic happy-path fill values.
|
|
749
|
+
|
|
750
|
+
Returns parsed list of test case dicts, or ``None`` on failure.
|
|
751
|
+
"""
|
|
752
|
+
if self._llm is None:
|
|
753
|
+
return None
|
|
754
|
+
from mannf.product.llm.prompts import UI_FORM_FILL_PROMPT # local import
|
|
755
|
+
|
|
756
|
+
fields_desc = self._format_fields_description(form.fields)
|
|
757
|
+
prompt = UI_FORM_FILL_PROMPT.format(
|
|
758
|
+
n=n,
|
|
759
|
+
locale=self._locale,
|
|
760
|
+
domain_context=self._domain_context or "general web application",
|
|
761
|
+
page_url=page.url,
|
|
762
|
+
form_action=form.action,
|
|
763
|
+
form_method=form.method,
|
|
764
|
+
fields_description=fields_desc,
|
|
765
|
+
)
|
|
766
|
+
try:
|
|
767
|
+
raw = await self._llm.generate(prompt)
|
|
768
|
+
return json.loads(raw)
|
|
769
|
+
except Exception as exc:
|
|
770
|
+
logger.warning("LLM form-fill call failed: %s", exc)
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
async def _llm_negative_path(
|
|
774
|
+
self, page: DiscoveredPage, form: DiscoveredForm, n: int = 3
|
|
775
|
+
) -> list[dict[str, Any]] | None:
|
|
776
|
+
"""Call LLM for negative/edge-case fill values.
|
|
777
|
+
|
|
778
|
+
Returns parsed list of test case dicts, or ``None`` on failure.
|
|
779
|
+
"""
|
|
780
|
+
if self._llm is None:
|
|
781
|
+
return None
|
|
782
|
+
from mannf.product.llm.prompts import UI_NEGATIVE_PATH_PROMPT # local import
|
|
783
|
+
|
|
784
|
+
fields_desc = self._format_fields_description(form.fields)
|
|
785
|
+
prompt = UI_NEGATIVE_PATH_PROMPT.format(
|
|
786
|
+
n=n,
|
|
787
|
+
page_url=page.url,
|
|
788
|
+
form_action=form.action,
|
|
789
|
+
form_method=form.method,
|
|
790
|
+
fields_description=fields_desc,
|
|
791
|
+
)
|
|
792
|
+
try:
|
|
793
|
+
raw = await self._llm.generate(prompt)
|
|
794
|
+
return json.loads(raw)
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
logger.warning("LLM negative-path call failed: %s", exc)
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
async def _llm_flow_suggestions(self, n: int = 3) -> list[dict[str, Any]] | None:
|
|
800
|
+
"""Call LLM for multi-step flow suggestions.
|
|
801
|
+
|
|
802
|
+
Returns parsed list of scenario dicts, or ``None`` on failure.
|
|
803
|
+
"""
|
|
804
|
+
if self._llm is None:
|
|
805
|
+
return None
|
|
806
|
+
from mannf.product.llm.prompts import UI_FLOW_SUGGESTION_PROMPT # local import
|
|
807
|
+
|
|
808
|
+
pages_desc = "\n".join(
|
|
809
|
+
f" - {p.url}: {p.title} ({len(p.forms)} forms, {len(p.links)} links)"
|
|
810
|
+
for p in self._model.pages
|
|
811
|
+
)
|
|
812
|
+
edges_desc = "\n".join(
|
|
813
|
+
f" - {e.from_url} --[{e.action}:{e.selector}]--> {e.to_url}"
|
|
814
|
+
for e in self._model.edges
|
|
815
|
+
)
|
|
816
|
+
prompt = UI_FLOW_SUGGESTION_PROMPT.format(
|
|
817
|
+
n=n,
|
|
818
|
+
base_url=self._model.base_url,
|
|
819
|
+
pages_description=pages_desc,
|
|
820
|
+
edges_description=edges_desc,
|
|
821
|
+
)
|
|
822
|
+
try:
|
|
823
|
+
raw = await self._llm.generate(prompt)
|
|
824
|
+
return json.loads(raw)
|
|
825
|
+
except Exception as exc:
|
|
826
|
+
logger.warning("LLM flow-suggestion call failed: %s", exc)
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
async def _llm_accessibility(
|
|
830
|
+
self, page: DiscoveredPage, form: DiscoveredForm, n: int = 3
|
|
831
|
+
) -> list[dict[str, Any]] | None:
|
|
832
|
+
"""Call LLM for accessibility-focused test cases.
|
|
833
|
+
|
|
834
|
+
Returns parsed list of test case dicts, or ``None`` on failure.
|
|
835
|
+
"""
|
|
836
|
+
if self._llm is None:
|
|
837
|
+
return None
|
|
838
|
+
from mannf.product.llm.prompts import UI_ACCESSIBILITY_PROMPT # local import
|
|
839
|
+
|
|
840
|
+
fields_desc = self._format_fields_description(form.fields)
|
|
841
|
+
prompt = UI_ACCESSIBILITY_PROMPT.format(
|
|
842
|
+
n=n,
|
|
843
|
+
page_url=page.url,
|
|
844
|
+
fields_description=fields_desc,
|
|
845
|
+
)
|
|
846
|
+
try:
|
|
847
|
+
raw = await self._llm.generate(prompt)
|
|
848
|
+
return json.loads(raw)
|
|
849
|
+
except Exception as exc:
|
|
850
|
+
logger.warning("LLM accessibility call failed: %s", exc)
|
|
851
|
+
return None
|
|
852
|
+
|
|
853
|
+
# ------------------------------------------------------------------
|
|
854
|
+
# LLM response → scenario dict converters
|
|
855
|
+
# ------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
def _convert_llm_form_fill_to_scenarios(
|
|
858
|
+
self,
|
|
859
|
+
page: DiscoveredPage,
|
|
860
|
+
form: DiscoveredForm,
|
|
861
|
+
fill_results: list[dict[str, Any]],
|
|
862
|
+
) -> list[dict[str, Any]]:
|
|
863
|
+
"""Convert ``_llm_form_fill`` results into standard scenario dicts.
|
|
864
|
+
|
|
865
|
+
Parameters
|
|
866
|
+
----------
|
|
867
|
+
page:
|
|
868
|
+
The page the form lives on.
|
|
869
|
+
form:
|
|
870
|
+
The form whose fields were filled.
|
|
871
|
+
fill_results:
|
|
872
|
+
List of dicts from the LLM, each with at least an ``"inputs"`` key
|
|
873
|
+
mapping ``selector → value``.
|
|
874
|
+
|
|
875
|
+
Returns
|
|
876
|
+
-------
|
|
877
|
+
list[dict]
|
|
878
|
+
Standard scenario dicts tagged with ``"generator": "llm"``.
|
|
879
|
+
"""
|
|
880
|
+
scenarios: list[dict[str, Any]] = []
|
|
881
|
+
for item in fill_results:
|
|
882
|
+
if not isinstance(item, dict):
|
|
883
|
+
continue
|
|
884
|
+
inputs: dict[str, str] = item.get("inputs", {})
|
|
885
|
+
if not isinstance(inputs, dict):
|
|
886
|
+
continue
|
|
887
|
+
description = item.get("description", "LLM-generated happy-path scenario")
|
|
888
|
+
fill_steps = [
|
|
889
|
+
{"action": "fill", "selector": selector, "value": str(value)}
|
|
890
|
+
for selector, value in inputs.items()
|
|
891
|
+
]
|
|
892
|
+
if form.submit_selector:
|
|
893
|
+
fill_steps.append({"action": "click", "selector": form.submit_selector})
|
|
894
|
+
steps = self._navigate_step(page.url) + fill_steps
|
|
895
|
+
scenario = {
|
|
896
|
+
"url": page.url,
|
|
897
|
+
"steps": steps,
|
|
898
|
+
"metadata": {
|
|
899
|
+
"scenario_type": "happy_path",
|
|
900
|
+
"source_page": page.url,
|
|
901
|
+
"source_form_action": form.action,
|
|
902
|
+
"description": f"LLM-generated: {description}",
|
|
903
|
+
"generator": "llm",
|
|
904
|
+
},
|
|
905
|
+
}
|
|
906
|
+
scenarios.append(scenario)
|
|
907
|
+
return scenarios
|
|
908
|
+
|
|
909
|
+
def _convert_llm_negative_to_scenarios(
|
|
910
|
+
self,
|
|
911
|
+
page: DiscoveredPage,
|
|
912
|
+
form: DiscoveredForm,
|
|
913
|
+
neg_results: list[dict[str, Any]],
|
|
914
|
+
) -> list[dict[str, Any]]:
|
|
915
|
+
"""Convert ``_llm_negative_path`` results into standard scenario dicts.
|
|
916
|
+
|
|
917
|
+
Parameters
|
|
918
|
+
----------
|
|
919
|
+
page:
|
|
920
|
+
The page the form lives on.
|
|
921
|
+
form:
|
|
922
|
+
The form whose fields are being tested negatively.
|
|
923
|
+
neg_results:
|
|
924
|
+
List of dicts from the LLM, each with at least an ``"inputs"`` key.
|
|
925
|
+
|
|
926
|
+
Returns
|
|
927
|
+
-------
|
|
928
|
+
list[dict]
|
|
929
|
+
Standard scenario dicts tagged with ``"generator": "llm"``.
|
|
930
|
+
"""
|
|
931
|
+
scenarios: list[dict[str, Any]] = []
|
|
932
|
+
for item in neg_results:
|
|
933
|
+
if not isinstance(item, dict):
|
|
934
|
+
continue
|
|
935
|
+
inputs: dict[str, str] = item.get("inputs", {})
|
|
936
|
+
if not isinstance(inputs, dict):
|
|
937
|
+
continue
|
|
938
|
+
description = item.get(
|
|
939
|
+
"expected_behavior",
|
|
940
|
+
item.get("description", "LLM-generated negative scenario"),
|
|
941
|
+
)
|
|
942
|
+
fill_steps = [
|
|
943
|
+
{"action": "fill", "selector": selector, "value": str(value)}
|
|
944
|
+
for selector, value in inputs.items()
|
|
945
|
+
]
|
|
946
|
+
if form.submit_selector:
|
|
947
|
+
fill_steps.append({"action": "click", "selector": form.submit_selector})
|
|
948
|
+
steps = self._navigate_step(page.url) + fill_steps
|
|
949
|
+
scenario = {
|
|
950
|
+
"url": page.url,
|
|
951
|
+
"steps": steps,
|
|
952
|
+
"metadata": {
|
|
953
|
+
"scenario_type": "negative",
|
|
954
|
+
"source_page": page.url,
|
|
955
|
+
"source_form_action": form.action,
|
|
956
|
+
"description": f"LLM-generated: {description}",
|
|
957
|
+
"generator": "llm",
|
|
958
|
+
},
|
|
959
|
+
}
|
|
960
|
+
scenarios.append(scenario)
|
|
961
|
+
return scenarios
|
|
962
|
+
|
|
963
|
+
def _convert_llm_flow_to_scenarios(
|
|
964
|
+
self,
|
|
965
|
+
flow_results: list[dict[str, Any]],
|
|
966
|
+
) -> list[dict[str, Any]]:
|
|
967
|
+
"""Convert ``_llm_flow_suggestions`` results into standard scenario dicts.
|
|
968
|
+
|
|
969
|
+
Parameters
|
|
970
|
+
----------
|
|
971
|
+
flow_results:
|
|
972
|
+
List of flow dicts from the LLM, each with ``"name"``, ``"steps"``,
|
|
973
|
+
``"description"``, and optional ``"priority"`` keys.
|
|
974
|
+
|
|
975
|
+
Returns
|
|
976
|
+
-------
|
|
977
|
+
list[dict]
|
|
978
|
+
Standard scenario dicts tagged with ``"generator": "llm"``.
|
|
979
|
+
"""
|
|
980
|
+
scenarios: list[dict[str, Any]] = []
|
|
981
|
+
base_url = self._model.base_url
|
|
982
|
+
for flow in flow_results:
|
|
983
|
+
if not isinstance(flow, dict):
|
|
984
|
+
continue
|
|
985
|
+
raw_steps: list[dict[str, Any]] = flow.get("steps", [])
|
|
986
|
+
if not isinstance(raw_steps, list):
|
|
987
|
+
raw_steps = []
|
|
988
|
+
# Derive the URL from the first navigate step, or fall back to base_url
|
|
989
|
+
url = base_url
|
|
990
|
+
for step in raw_steps:
|
|
991
|
+
if isinstance(step, dict) and step.get("action") == "navigate":
|
|
992
|
+
sel = step.get("selector", "")
|
|
993
|
+
if sel:
|
|
994
|
+
url = sel if sel.startswith("http") else f"{base_url.rstrip('/')}/{sel.lstrip('/')}"
|
|
995
|
+
break
|
|
996
|
+
description = flow.get("description", flow.get("name", "LLM-generated flow"))
|
|
997
|
+
scenario = {
|
|
998
|
+
"url": url,
|
|
999
|
+
"steps": raw_steps,
|
|
1000
|
+
"metadata": {
|
|
1001
|
+
"scenario_type": "flow",
|
|
1002
|
+
"source_page": url,
|
|
1003
|
+
"source_form_action": "",
|
|
1004
|
+
"description": f"LLM-generated: {description}",
|
|
1005
|
+
"generator": "llm",
|
|
1006
|
+
},
|
|
1007
|
+
}
|
|
1008
|
+
scenarios.append(scenario)
|
|
1009
|
+
return scenarios
|
|
1010
|
+
|
|
1011
|
+
# ------------------------------------------------------------------
|
|
1012
|
+
# AdaptiveController ranking
|
|
1013
|
+
# ------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
def _rank_by_adaptive_controller(
|
|
1016
|
+
self, scenarios: list[dict[str, Any]]
|
|
1017
|
+
) -> list[dict[str, Any]]:
|
|
1018
|
+
"""Sort *scenarios* by descending risk score using the AdaptiveController.
|
|
1019
|
+
|
|
1020
|
+
When no ``adaptive_controller`` is set the input list is returned
|
|
1021
|
+
unchanged. When one is set, each scenario's target page URL is scored
|
|
1022
|
+
via ``_endpoint_risk()`` and the scenarios are re-ordered so that
|
|
1023
|
+
higher-risk pages appear first. The computed ``risk_score`` and
|
|
1024
|
+
``confidence`` values are also stored in each scenario's ``metadata``
|
|
1025
|
+
so downstream consumers can surface them directly.
|
|
1026
|
+
|
|
1027
|
+
Parameters
|
|
1028
|
+
----------
|
|
1029
|
+
scenarios:
|
|
1030
|
+
List of scenario dicts to rank.
|
|
1031
|
+
|
|
1032
|
+
Returns
|
|
1033
|
+
-------
|
|
1034
|
+
list[dict]
|
|
1035
|
+
Scenarios sorted by descending risk (highest-risk first) when an
|
|
1036
|
+
adaptive controller is available; otherwise the original list.
|
|
1037
|
+
"""
|
|
1038
|
+
if self._ac is None or not scenarios:
|
|
1039
|
+
return scenarios
|
|
1040
|
+
|
|
1041
|
+
def _risk(scenario: dict[str, Any]) -> float:
|
|
1042
|
+
target = scenario.get("metadata", {}).get("source_page") or scenario.get("url", "")
|
|
1043
|
+
belief_states = getattr(self._ac, "belief_states", None) or []
|
|
1044
|
+
try:
|
|
1045
|
+
return self._ac._endpoint_risk(target, belief_states)
|
|
1046
|
+
except Exception: # noqa: BLE001
|
|
1047
|
+
return 0.5
|
|
1048
|
+
|
|
1049
|
+
for scenario in scenarios:
|
|
1050
|
+
risk = _risk(scenario)
|
|
1051
|
+
meta = scenario.setdefault("metadata", {})
|
|
1052
|
+
meta["risk_score"] = round(risk, 4)
|
|
1053
|
+
meta["confidence"] = round(max(0.0, 1.0 - risk * 0.5), 4)
|
|
1054
|
+
|
|
1055
|
+
return sorted(scenarios, key=_risk, reverse=True)
|
|
1056
|
+
|
|
1057
|
+
# ------------------------------------------------------------------
|
|
1058
|
+
# Internal helpers
|
|
1059
|
+
# ------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
@staticmethod
|
|
1062
|
+
def _navigate_step(url: str) -> list[dict[str, Any]]:
|
|
1063
|
+
"""Return a navigate step list for the given URL."""
|
|
1064
|
+
return [{"action": "navigate", "selector": url}]
|
|
1065
|
+
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
def _make_scenario(
|
|
1068
|
+
page: DiscoveredPage,
|
|
1069
|
+
form: DiscoveredForm,
|
|
1070
|
+
steps: list[dict[str, Any]],
|
|
1071
|
+
scenario_type: str,
|
|
1072
|
+
description: str,
|
|
1073
|
+
) -> dict[str, Any]:
|
|
1074
|
+
return {
|
|
1075
|
+
"url": page.url,
|
|
1076
|
+
"steps": steps,
|
|
1077
|
+
"metadata": {
|
|
1078
|
+
"scenario_type": scenario_type,
|
|
1079
|
+
"source_page": page.url,
|
|
1080
|
+
"source_form_action": form.action,
|
|
1081
|
+
"description": description,
|
|
1082
|
+
},
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
@staticmethod
|
|
1086
|
+
def _format_fields_description(fields: list[DiscoveredField]) -> str:
|
|
1087
|
+
lines: list[str] = []
|
|
1088
|
+
for f in fields:
|
|
1089
|
+
lines.append(f" - selector={f.selector!r}")
|
|
1090
|
+
lines.append(f" type={f.type!r}")
|
|
1091
|
+
if f.label:
|
|
1092
|
+
lines.append(f" label={f.label!r}")
|
|
1093
|
+
if f.name:
|
|
1094
|
+
lines.append(f" name={f.name!r}")
|
|
1095
|
+
lines.append(f" required={f.required}")
|
|
1096
|
+
if f.placeholder:
|
|
1097
|
+
lines.append(f" placeholder={f.placeholder!r}")
|
|
1098
|
+
if f.pattern:
|
|
1099
|
+
lines.append(f" pattern={f.pattern!r} ← values MUST match this regex")
|
|
1100
|
+
return "\n".join(lines)
|