jac-loadtest 0.1.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/PKG-INFO +20 -1
  2. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/README.md +19 -0
  3. jac_loadtest-0.2.2/jac_loadtest/bridge/auth.py +114 -0
  4. jac_loadtest-0.2.2/jac_loadtest/bridge/topology.py +201 -0
  5. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/cli.py +35 -5
  6. jac_loadtest-0.2.2/jac_loadtest/config.py +149 -0
  7. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/engine.py +78 -12
  8. jac_loadtest-0.2.2/jac_loadtest/core/har_parser.py +279 -0
  9. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/metrics.py +15 -6
  10. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/output/reporter.py +32 -8
  11. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/plugin.py +16 -16
  12. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/PKG-INFO +20 -1
  13. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/pyproject.toml +1 -1
  14. jac_loadtest-0.1.0/jac_loadtest/bridge/auth.py +0 -15
  15. jac_loadtest-0.1.0/jac_loadtest/bridge/topology.py +0 -24
  16. jac_loadtest-0.1.0/jac_loadtest/config.py +0 -123
  17. jac_loadtest-0.1.0/jac_loadtest/core/har_parser.py +0 -161
  18. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/__init__.py +0 -0
  19. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/bridge/__init__.py +0 -0
  20. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/__init__.py +0 -0
  21. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/output/__init__.py +0 -0
  22. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/SOURCES.txt +0 -0
  23. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/dependency_links.txt +0 -0
  24. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/entry_points.txt +0 -0
  25. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/requires.txt +0 -0
  26. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/top_level.txt +0 -0
  27. {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.1.0
3
+ Version: 0.2.2
4
4
  Summary: HAR-based load testing CLI for jac-scale applications
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -19,6 +19,25 @@ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci
19
19
 
20
20
  The tool registers itself as a `jac` subcommand, so after installation you run `jac loadtest` alongside `jac start`, `jac deploy`, and the rest of the jac ecosystem.
21
21
 
22
+ ## Testing Modes
23
+
24
+ **Monolith mode** (default) — all requests go through a single `--url`. Use this for production-realistic load testing: it measures what users actually experience end-to-end through the gateway.
25
+
26
+ **Microservice mode** — route requests directly to individual service processes by URL path prefix. Use this locally or inside your cluster to isolate per-service latency and identify which service is the bottleneck — without gateway overhead masking the signal.
27
+
28
+ ```bash
29
+ # Monolith: all traffic through the gateway (default, production-realistic)
30
+ jac loadtest recording.har --url http://localhost:8000 --vus 10 --duration 30s
31
+
32
+ # Microservice: bypass gateway, route by path prefix to individual services
33
+ jac loadtest recording.har --mode microservice \
34
+ --url http://localhost:8000 \
35
+ --services-map '{"order_service":"http://localhost:18001","inventory_service":"http://localhost:18002"}' \
36
+ --vus 10 --duration 30s
37
+ ```
38
+
39
+ > **Note:** Microservice mode requires direct network access to service ports. This means it's only usable locally (`jac serve`) or from inside a Kubernetes cluster — not from outside production. For remote or production load testing, use monolith mode.
40
+
22
41
  ## Quick Start
23
42
 
24
43
  ```bash
@@ -4,6 +4,25 @@ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci
4
4
 
5
5
  The tool registers itself as a `jac` subcommand, so after installation you run `jac loadtest` alongside `jac start`, `jac deploy`, and the rest of the jac ecosystem.
6
6
 
7
+ ## Testing Modes
8
+
9
+ **Monolith mode** (default) — all requests go through a single `--url`. Use this for production-realistic load testing: it measures what users actually experience end-to-end through the gateway.
10
+
11
+ **Microservice mode** — route requests directly to individual service processes by URL path prefix. Use this locally or inside your cluster to isolate per-service latency and identify which service is the bottleneck — without gateway overhead masking the signal.
12
+
13
+ ```bash
14
+ # Monolith: all traffic through the gateway (default, production-realistic)
15
+ jac loadtest recording.har --url http://localhost:8000 --vus 10 --duration 30s
16
+
17
+ # Microservice: bypass gateway, route by path prefix to individual services
18
+ jac loadtest recording.har --mode microservice \
19
+ --url http://localhost:8000 \
20
+ --services-map '{"order_service":"http://localhost:18001","inventory_service":"http://localhost:18002"}' \
21
+ --vus 10 --duration 30s
22
+ ```
23
+
24
+ > **Note:** Microservice mode requires direct network access to service ports. This means it's only usable locally (`jac serve`) or from inside a Kubernetes cluster — not from outside production. For remote or production load testing, use monolith mode.
25
+
7
26
  ## Quick Start
8
27
 
9
28
  ```bash
@@ -0,0 +1,114 @@
1
+ """jac-scale auth: per-VU JWT login and header injection.
2
+
3
+ AuthProvider loads credentials from a CSV file or shared username/password,
4
+ authenticates each VU independently against /user/login, and returns the
5
+ Bearer token for injection into subsequent requests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ import aiohttp
14
+
15
+ if TYPE_CHECKING:
16
+ from jac_loadtest.config import LoadTestConfig
17
+
18
+
19
+ class AuthenticationError(Exception):
20
+ """Raised when login fails; carries a human-readable message for the user."""
21
+
22
+
23
+ @dataclass
24
+ class Credential:
25
+ username: str
26
+ password: str
27
+
28
+
29
+ class AuthProvider:
30
+ def __init__(
31
+ self,
32
+ credentials: list[Credential],
33
+ login_path: str = "/user/login",
34
+ ) -> None:
35
+ self._credentials = credentials
36
+ self._login_path = login_path
37
+
38
+ @classmethod
39
+ def from_config(cls, config: LoadTestConfig) -> AuthProvider | None:
40
+ """Return an AuthProvider if credentials are configured, else None."""
41
+ if config.credentials_file:
42
+ creds = _load_csv(config.credentials_file)
43
+ elif config.username and config.password:
44
+ creds = [Credential(config.username, config.password)]
45
+ else:
46
+ return None
47
+ return cls(creds, config.login_path)
48
+
49
+ def get_credential(self, vu_id: int) -> Credential:
50
+ """Wrap-around assignment: VU i gets row i % len(credentials)."""
51
+ return self._credentials[vu_id % len(self._credentials)]
52
+
53
+ async def authenticate(
54
+ self,
55
+ vu_id: int,
56
+ session: aiohttp.ClientSession,
57
+ base_url: str,
58
+ ) -> str:
59
+ """POST /user/login for VU vu_id and return the JWT token string.
60
+
61
+ identity.type is inferred: values containing '@' use 'email', else 'username'.
62
+ Raises AuthenticationError on 4xx/5xx so callers get a clear message.
63
+ """
64
+ cred = self.get_credential(vu_id)
65
+ identity_type = "email" if "@" in cred.username else "username"
66
+ payload = {
67
+ "identity": {"type": identity_type, "value": cred.username},
68
+ "credential": {"type": "password", "password": cred.password},
69
+ }
70
+ url = base_url.rstrip("/") + self._login_path
71
+ try:
72
+ async with session.post(url, json=payload) as resp:
73
+ if resp.status == 401:
74
+ raise AuthenticationError(
75
+ f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
76
+ f"401 Unauthorized — check credentials."
77
+ )
78
+ if not resp.ok:
79
+ raise AuthenticationError(
80
+ f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
81
+ f"server returned {resp.status}."
82
+ )
83
+ body = await resp.json()
84
+ except aiohttp.ClientConnectorError as exc:
85
+ raise AuthenticationError(
86
+ f"Cannot reach login endpoint {url!r}: {exc}"
87
+ ) from exc
88
+ try:
89
+ return body["data"]["token"]
90
+ except (KeyError, TypeError) as exc:
91
+ raise AuthenticationError(
92
+ f"Unexpected login response shape from {url!r}: {body!r}"
93
+ ) from exc
94
+
95
+
96
+ def _load_csv(path: str) -> list[Credential]:
97
+ """Read credentials.csv and return a list of Credential objects.
98
+
99
+ Skips a header row if the first column value is 'username' (case-insensitive).
100
+ Raises ValueError if the file is empty or has no valid rows.
101
+ """
102
+ credentials: list[Credential] = []
103
+ with open(path, newline="") as f:
104
+ reader = csv.reader(f)
105
+ for i, row in enumerate(reader):
106
+ if len(row) < 2:
107
+ continue
108
+ username, password = row[0].strip(), row[1].strip()
109
+ if i == 0 and username.lower() == "username":
110
+ continue
111
+ credentials.append(Credential(username=username, password=password))
112
+ if not credentials:
113
+ raise ValueError(f"No credentials found in {path!r}")
114
+ return credentials
@@ -0,0 +1,201 @@
1
+ """Build prefix→URL routing table from jac-scale ServiceRegistry or jac.toml.
2
+
3
+ Implements longest-prefix matching to mirror jac-scale's gateway routing algorithm:
4
+ path == prefix OR path.startswith(prefix + "/")
5
+ Empty prefix is a catch-all (used for monolith mode).
6
+
7
+ Implemented in Phase 3.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from urllib.parse import urlparse, urlunparse
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from jac_loadtest.config import LoadTestConfig
19
+
20
+
21
+ @dataclass
22
+ class ServiceRoute:
23
+ name: str # service label used in metrics/report (e.g. "order_service")
24
+ prefix: str # path prefix for routing (e.g. "/walker/order"); "" = catch-all
25
+ url: str # service base URL, no trailing slash (e.g. "http://localhost:18001")
26
+
27
+
28
+ class TopologyRouter:
29
+ """Routes a HAR entry URL to the correct service URL and returns the service name.
30
+
31
+ Construction: TopologyRouter.from_config(config) is the normal entry point.
32
+ Direct construction (TopologyRouter(routes, fallback_url)) is used in tests.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ routes: list[ServiceRoute],
38
+ fallback_url: str | None = None,
39
+ ) -> None:
40
+ # Sort once at construction — longest prefix first (mirrors ServiceRegistry)
41
+ self._routes = sorted(routes, key=lambda r: len(r.prefix), reverse=True)
42
+ self._fallback_url = fallback_url
43
+
44
+ def resolve(self, entry_url: str) -> tuple[str, str]:
45
+ """Return (routed_full_url, service_name) for the given HAR entry URL.
46
+
47
+ Reconstructs the full URL using the matched service's base + original path + query.
48
+ Match logic mirrors jac-scale ServiceRegistry.match_route() exactly:
49
+ empty prefix → catch-all (monolith)
50
+ path == prefix OR path.startswith(prefix + "/")
51
+ """
52
+ parsed = urlparse(entry_url)
53
+ path = parsed.path
54
+
55
+ for route in self._routes:
56
+ if _prefix_matches(path, route.prefix):
57
+ t = urlparse(route.url)
58
+ # Strip the gateway prefix before sending directly to the service.
59
+ # jac-scale's gateway strips the route prefix when forwarding; we replicate
60
+ # that so the service receives the path it actually handles.
61
+ # Empty prefix (monolith catch-all) keeps the path unchanged.
62
+ if route.prefix:
63
+ service_path = path[len(route.prefix):] or "/"
64
+ else:
65
+ service_path = path
66
+ base_path = t.path.rstrip("/") if t.path else ""
67
+ routed_path = f"{base_path}{service_path}" if base_path else service_path
68
+ routed = urlunparse((
69
+ t.scheme, t.netloc,
70
+ routed_path, parsed.params, parsed.query, "",
71
+ ))
72
+ return routed, route.name
73
+
74
+ if self._fallback_url:
75
+ t = urlparse(self._fallback_url)
76
+ base_path = t.path.rstrip("/") if t.path else ""
77
+ routed_path = f"{base_path}{path}" if base_path else path
78
+ routed = urlunparse((
79
+ t.scheme, t.netloc,
80
+ routed_path, parsed.params, parsed.query, "",
81
+ ))
82
+ return routed, "gateway"
83
+
84
+ raise ValueError(
85
+ f"No route for path '{path}' and no --url fallback provided. "
86
+ "Use --services-map or add [plugins.scale.microservices.routes] to jac.toml."
87
+ )
88
+
89
+ @classmethod
90
+ def from_config(cls, config: LoadTestConfig) -> TopologyRouter:
91
+ """Build a TopologyRouter from LoadTestConfig.
92
+
93
+ Monolith mode: single catch-all route to config.url.
94
+ Microservice mode: --services-map JSON (highest priority) or jac.toml auto-discovery.
95
+ """
96
+ if config.mode != "microservice":
97
+ return cls(
98
+ routes=[ServiceRoute(name="monolith", prefix="", url=config.url or "")],
99
+ fallback_url=None,
100
+ )
101
+ return cls._build_microservice(config)
102
+
103
+ @classmethod
104
+ def _build_microservice(cls, config: LoadTestConfig) -> TopologyRouter:
105
+ toml_routes = _load_toml_routes() # service_name → prefix; {} if unavailable
106
+
107
+ routes: list[ServiceRoute]
108
+
109
+ if config.services_map:
110
+ try:
111
+ services_json: dict[str, str] = json.loads(config.services_map)
112
+ except json.JSONDecodeError as exc:
113
+ raise ValueError(f"--services-map is not valid JSON: {exc}") from exc
114
+
115
+ routes = [
116
+ ServiceRoute(
117
+ # Strip leading slash from name so service labels display cleanly
118
+ name=name.lstrip("/") if name.startswith("/") else name,
119
+ # Key starting with "/" is used directly as prefix; otherwise derive from key
120
+ prefix=toml_routes.get(name, name if name.startswith("/") else f"/{name}"),
121
+ url=url.rstrip("/"),
122
+ )
123
+ for name, url in services_json.items()
124
+ ]
125
+ return cls(routes, fallback_url=config.url)
126
+
127
+ # Auto-discovery: jac.toml routes + JAC_SV_<NAME>_URL env vars
128
+ if not toml_routes:
129
+ raise ValueError(
130
+ "Microservice mode requires either --services-map or "
131
+ "[plugins.scale.microservices.routes] in jac.toml. Neither was found."
132
+ )
133
+
134
+ routes = []
135
+ missing: list[str] = []
136
+ for name, prefix in toml_routes.items():
137
+ env_var = f"JAC_SV_{name.upper()}_URL"
138
+ url = os.environ.get(env_var)
139
+ if not url:
140
+ missing.append(env_var)
141
+ else:
142
+ routes.append(ServiceRoute(name=name, prefix=prefix, url=url.rstrip("/")))
143
+
144
+ if missing:
145
+ raise ValueError(
146
+ f"Microservice mode: missing environment variable(s): {', '.join(missing)}. "
147
+ "Expected format: JAC_SV_<SERVICE_NAME>_URL=http://host:port"
148
+ )
149
+
150
+ return cls(routes, fallback_url=config.url)
151
+
152
+
153
+ def _prefix_matches(path: str, prefix: str) -> bool:
154
+ """Return True if path matches prefix using jac-scale's exact routing semantics."""
155
+ if not prefix:
156
+ return True # empty prefix = catch-all (monolith)
157
+ return path == prefix or path.startswith(prefix + "/")
158
+
159
+
160
+ def _load_toml_routes() -> dict[str, str]:
161
+ """Read [plugins.scale.microservices.routes] from jac.toml.
162
+
163
+ Returns {service_name: prefix} dict.
164
+ Returns {} on any error (missing file, missing section, jac_scale unavailable).
165
+
166
+ Isolated as a module-level function so tests can monkeypatch it without
167
+ importing jac_scale at all — keeping topology unit tests jac-scale-free.
168
+ """
169
+ try:
170
+ from pathlib import Path
171
+ from jac_scale.config_loader import get_scale_config, reset_scale_config
172
+ reset_scale_config()
173
+ ms_config = get_scale_config(project_dir=Path.cwd()).get_microservices_config()
174
+ return dict(ms_config.get("routes", {}))
175
+ except Exception:
176
+ return {}
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Backward-compat shims — kept so any external code that imported these names
181
+ # does not break. Neither is called by production or test code in this repo.
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def build_routing_table(
185
+ mode: str,
186
+ base_url: str | None = None,
187
+ services_map_json: str | None = None,
188
+ ) -> dict[str, str]:
189
+ raise NotImplementedError(
190
+ "build_routing_table() is deprecated. Use TopologyRouter.from_config() instead."
191
+ )
192
+
193
+
194
+ def resolve_url(path: str, routing_table: dict[str, str], fallback_url: str | None) -> str:
195
+ """Longest-prefix match: mirrors jac-scale ServiceRegistry.match_route()."""
196
+ for prefix in sorted(routing_table, key=len, reverse=True):
197
+ if path.startswith(prefix):
198
+ return routing_table[prefix]
199
+ if fallback_url:
200
+ return fallback_url
201
+ raise ValueError(f"No route for path '{path}' and no --url fallback provided.")
@@ -19,18 +19,20 @@ def run(args: object) -> None:
19
19
 
20
20
  config = from_args(args)
21
21
 
22
- if not config.url:
23
- print("Error: --url is required", file=sys.stderr)
22
+ # --url required for monolith mode; optional for microservice (becomes fallback)
23
+ if config.mode == "monolith" and not config.url:
24
+ print("Error: --url is required for monolith mode", file=sys.stderr)
24
25
  sys.exit(2)
25
26
 
26
27
  if not config.har_file:
27
28
  print("Error: har_file positional argument is required", file=sys.stderr)
28
29
  sys.exit(2)
29
30
 
31
+ # Parse HAR — monolith rewrites all URLs to config.url; microservice keeps originals
30
32
  try:
31
33
  entries = parse_har(
32
34
  config.har_file,
33
- target_url=config.url,
35
+ target_url=config.url if config.mode == "monolith" else None,
34
36
  include_static=config.include_static,
35
37
  login_path=config.login_path,
36
38
  )
@@ -46,11 +48,39 @@ def run(args: object) -> None:
46
48
  )
47
49
  sys.exit(2)
48
50
 
51
+ from jac_loadtest.bridge.auth import AuthProvider, AuthenticationError
52
+
53
+ auth_provider = AuthProvider.from_config(config)
54
+
55
+ # In microservice mode, auth still goes to the gateway (--url); require it when set
56
+ if config.mode == "microservice" and auth_provider is not None and not config.url:
57
+ print(
58
+ "Error: --url (gateway URL) is required when using authentication "
59
+ "in microservice mode",
60
+ file=sys.stderr,
61
+ )
62
+ sys.exit(2)
63
+
64
+ # Build topology router — validates service map JSON and service URL availability
65
+ from jac_loadtest.bridge.topology import TopologyRouter
66
+
67
+ try:
68
+ topology = TopologyRouter.from_config(config)
69
+ except ValueError as exc:
70
+ print(f"Error: {exc}", file=sys.stderr)
71
+ sys.exit(2)
72
+
49
73
  metrics = MetricsCollector(max_samples=config.max_samples)
50
74
  t_start = time.time()
51
75
 
52
- asyncio.run(run_all_vus(entries, config, metrics))
76
+ try:
77
+ asyncio.run(
78
+ run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
79
+ )
80
+ except AuthenticationError as exc:
81
+ print(f"Error: {exc}", file=sys.stderr)
82
+ sys.exit(2)
53
83
 
54
84
  duration_s = time.time() - t_start
55
85
  stats = metrics.compute_endpoint_stats(duration_s)
56
- render_console(stats, config)
86
+ render_console(stats, config, actual_duration_s=duration_s)
@@ -0,0 +1,149 @@
1
+ """LoadTestConfig — three-layer resolution: jac.toml → CLI flags → built-in defaults.
2
+
3
+ Phase 0: dataclass with built-in defaults only.
4
+ Phase 2 will add jac.toml reading via jac_scale.config_loader.
5
+ """
6
+ from __future__ import annotations
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ BUILT_IN_DEFAULTS: dict = {
12
+ "vus": 1,
13
+ "duration": "30s",
14
+ "iterations": 1,
15
+ "ramp_up": "0s",
16
+ "timeout": "30s",
17
+ "mode": "monolith",
18
+ "think_time": "none",
19
+ "think_time_scale": 1.0,
20
+ "rps": 0,
21
+ "include_static": False,
22
+ "login_path": "/user/login",
23
+ "fail_on_error_rate": None,
24
+ "fail_on_p95": None,
25
+ "fail_on_p99": None,
26
+ "abort_on_fail": False,
27
+ "threshold_start_delay": "0s",
28
+ "report_format": "console",
29
+ "max_samples": 1_000_000,
30
+ "csrf": False,
31
+ "debug": False,
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class LoadTestConfig:
37
+ # Load shape
38
+ vus: int = 1
39
+ duration: str = "30s"
40
+ iterations: int = 1
41
+ ramp_up: str = "0s"
42
+ timeout: str = "30s"
43
+
44
+ # Traffic
45
+ mode: str = "monolith"
46
+ think_time: str = "none"
47
+ think_time_scale: float = 1.0
48
+ rps: int = 0
49
+ include_static: bool = False
50
+
51
+ # Auth
52
+ login_path: str = "/user/login"
53
+ csrf: bool = False
54
+
55
+ # CI thresholds
56
+ fail_on_error_rate: float | None = None
57
+ fail_on_p95: float | None = None
58
+ fail_on_p99: float | None = None
59
+ abort_on_fail: bool = False
60
+ threshold_start_delay: str = "0s"
61
+
62
+ # Output
63
+ report_format: str = "console"
64
+ max_samples: int = 1_000_000
65
+ debug: bool = False
66
+
67
+ # CLI-only — not sourced from jac.toml (environment-specific or security-sensitive)
68
+ har_file: str = ""
69
+ url: str | None = None
70
+ username: str | None = None
71
+ password: str | None = None
72
+ credentials_file: str | None = None
73
+ services_map: str | None = None
74
+ report_out: str | None = None
75
+
76
+
77
+ def parse_duration(s: str) -> float:
78
+ """Convert a duration string ('30s', '2m', '1h') to seconds."""
79
+ s = s.strip()
80
+ if s.endswith("h"):
81
+ return float(s[:-1]) * 3600
82
+ if s.endswith("m"):
83
+ return float(s[:-1]) * 60
84
+ if s.endswith("s"):
85
+ return float(s[:-1])
86
+ return float(s)
87
+
88
+
89
+ def _load_toml_defaults() -> dict:
90
+ """Read [plugins.scale.loadtest] from jac.toml using jac-scale's native config API.
91
+
92
+ Returns an empty dict if jac.toml is absent, the section is missing, or
93
+ the import fails (e.g. jac-scale not installed in a minimal test env).
94
+ """
95
+ try:
96
+ from pathlib import Path
97
+ from jac_scale.config_loader import get_scale_config, reset_scale_config
98
+ reset_scale_config()
99
+ scale_config = get_scale_config(project_dir=Path.cwd())
100
+ return scale_config.get_section("loadtest")
101
+ except Exception:
102
+ return {}
103
+
104
+
105
+ def from_args(args: object) -> LoadTestConfig:
106
+ """Build LoadTestConfig using three-layer resolution: CLI > jac.toml > built-in defaults."""
107
+ toml = _load_toml_defaults()
108
+
109
+ # For toml-sourced fields, use CLI value if provided (not None), else toml value,
110
+ # else built-in default.
111
+ def resolve(name: str) -> Any:
112
+ cli_val = getattr(args, name, None)
113
+ if cli_val is not None:
114
+ return cli_val
115
+ if name in toml:
116
+ return toml[name]
117
+ return BUILT_IN_DEFAULTS.get(name)
118
+
119
+ return LoadTestConfig(
120
+ # CLI-only fields: not sourced from jac.toml
121
+ har_file=getattr(args, "har_file", "") or "",
122
+ url=getattr(args, "url", None),
123
+ username=getattr(args, "username", None),
124
+ password=getattr(args, "password", None),
125
+ credentials_file=getattr(args, "credentials_file", None),
126
+ services_map=getattr(args, "services_map", None),
127
+ report_out=getattr(args, "report_out", None),
128
+ # Three-layer resolved fields
129
+ iterations=resolve("iterations"),
130
+ mode=resolve("mode"),
131
+ vus=resolve("vus"),
132
+ duration=resolve("duration"),
133
+ ramp_up=resolve("ramp_up"),
134
+ timeout=resolve("timeout"),
135
+ think_time=resolve("think_time"),
136
+ think_time_scale=resolve("think_time_scale"),
137
+ login_path=resolve("login_path"),
138
+ include_static=resolve("include_static"),
139
+ rps=resolve("rps"),
140
+ max_samples=resolve("max_samples"),
141
+ csrf=resolve("csrf"),
142
+ fail_on_error_rate=resolve("fail_on_error_rate"),
143
+ fail_on_p95=resolve("fail_on_p95"),
144
+ fail_on_p99=resolve("fail_on_p99"),
145
+ abort_on_fail=resolve("abort_on_fail"),
146
+ threshold_start_delay=resolve("threshold_start_delay"),
147
+ report_format=resolve("report_format"),
148
+ debug=resolve("debug"),
149
+ )