jac-loadtest 0.2.1__tar.gz → 0.2.3__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.2.1 → jac_loadtest-0.2.3}/PKG-INFO +1 -1
  2. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/topology.py +3 -1
  3. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/cli.py +62 -7
  4. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/config.py +8 -3
  5. jac_loadtest-0.2.3/jac_loadtest/core/engine.py +343 -0
  6. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/har_parser.py +55 -9
  7. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/metrics.py +61 -25
  8. jac_loadtest-0.2.3/jac_loadtest/core/process_runner.py +195 -0
  9. jac_loadtest-0.2.3/jac_loadtest/output/reporter.py +307 -0
  10. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/plugin.py +4 -2
  11. jac_loadtest-0.2.3/jac_loadtest/templates/reporter_template.html +204 -0
  12. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/PKG-INFO +1 -1
  13. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/SOURCES.txt +3 -1
  14. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/pyproject.toml +4 -1
  15. jac_loadtest-0.2.1/jac_loadtest/core/engine.py +0 -212
  16. jac_loadtest-0.2.1/jac_loadtest/output/reporter.py +0 -115
  17. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/README.md +0 -0
  18. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/__init__.py +0 -0
  19. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/__init__.py +0 -0
  20. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/auth.py +0 -0
  21. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/__init__.py +0 -0
  22. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/output/__init__.py +0 -0
  23. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/dependency_links.txt +0 -0
  24. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/entry_points.txt +0 -0
  25. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/requires.txt +0 -0
  26. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/top_level.txt +0 -0
  27. {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -104,6 +104,8 @@ class TopologyRouter:
104
104
  def _build_microservice(cls, config: LoadTestConfig) -> TopologyRouter:
105
105
  toml_routes = _load_toml_routes() # service_name → prefix; {} if unavailable
106
106
 
107
+ routes: list[ServiceRoute]
108
+
107
109
  if config.services_map:
108
110
  try:
109
111
  services_json: dict[str, str] = json.loads(config.services_map)
@@ -129,7 +131,7 @@ class TopologyRouter:
129
131
  "[plugins.scale.microservices.routes] in jac.toml. Neither was found."
130
132
  )
131
133
 
132
- routes: list[ServiceRoute] = []
134
+ routes = []
133
135
  missing: list[str] = []
134
136
  for name, prefix in toml_routes.items():
135
137
  env_var = f"JAC_SV_{name.upper()}_URL"
@@ -15,7 +15,7 @@ def run(args: object) -> None:
15
15
  from jac_loadtest.core.har_parser import parse_har
16
16
  from jac_loadtest.core.engine import run_all_vus
17
17
  from jac_loadtest.core.metrics import MetricsCollector
18
- from jac_loadtest.output.reporter import render_console
18
+ from jac_loadtest.output.reporter import render_console, render_json, render_html
19
19
 
20
20
  config = from_args(args)
21
21
 
@@ -70,17 +70,72 @@ def run(args: object) -> None:
70
70
  print(f"Error: {exc}", file=sys.stderr)
71
71
  sys.exit(2)
72
72
 
73
- metrics = MetricsCollector(max_samples=config.max_samples)
73
+ from rich.console import Console as _Console
74
+ _console = _Console(stderr=True)
75
+
74
76
  t_start = time.time()
75
77
 
76
78
  try:
77
- asyncio.run(
78
- run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
79
- )
79
+ with _console.status(f"Running load test — {config.vus} VUs..."):
80
+ if config.workers > 1:
81
+ from jac_loadtest.core.process_runner import run_multiprocess
82
+ metrics = run_multiprocess(entries, config, topology, auth_provider)
83
+ else:
84
+ metrics = MetricsCollector(max_samples=config.max_samples)
85
+ asyncio.run(
86
+ run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
87
+ )
80
88
  except AuthenticationError as exc:
81
89
  print(f"Error: {exc}", file=sys.stderr)
82
90
  sys.exit(2)
83
91
 
84
92
  duration_s = time.time() - t_start
85
- stats = metrics.compute_endpoint_stats(duration_s)
86
- render_console(stats, config, actual_duration_s=duration_s)
93
+ stats = metrics.compute_endpoint_stats()
94
+ snapshots = metrics.generate_timeseries(t_start)
95
+ completion_p50, completion_p95, completion_p99 = metrics.completion_percentiles(t_start)
96
+
97
+ fmt = config.report_format
98
+
99
+ if fmt == "json":
100
+ output = render_json(
101
+ stats, config,
102
+ actual_duration_s=duration_s,
103
+ total_rps=metrics.global_rps(duration_s),
104
+ snapshots=snapshots,
105
+ completion_p50_s=completion_p50,
106
+ completion_p95_s=completion_p95,
107
+ completion_p99_s=completion_p99,
108
+ )
109
+ if config.report_out:
110
+ with open(config.report_out, "w", encoding="utf-8") as fh:
111
+ fh.write(output)
112
+ print(f"JSON report written to {config.report_out}")
113
+ else:
114
+ print(output)
115
+
116
+ elif fmt == "html":
117
+ if not config.report_out:
118
+ print("Error: --report-out <path> is required for --report-format html", file=sys.stderr)
119
+ sys.exit(2)
120
+ output = render_html(
121
+ stats, config,
122
+ actual_duration_s=duration_s,
123
+ total_rps=metrics.global_rps(duration_s),
124
+ snapshots=snapshots,
125
+ completion_p50_s=completion_p50,
126
+ completion_p95_s=completion_p95,
127
+ completion_p99_s=completion_p99,
128
+ )
129
+ with open(config.report_out, "w", encoding="utf-8") as fh:
130
+ fh.write(output)
131
+ print(f"HTML report written to {config.report_out}", file=sys.stderr)
132
+
133
+ else:
134
+ render_console(
135
+ stats, config,
136
+ actual_duration_s=duration_s,
137
+ total_rps=metrics.global_rps(duration_s),
138
+ completion_p50_s=completion_p50,
139
+ completion_p95_s=completion_p95,
140
+ completion_p99_s=completion_p99,
141
+ )
@@ -4,15 +4,18 @@ Phase 0: dataclass with built-in defaults only.
4
4
  Phase 2 will add jac.toml reading via jac_scale.config_loader.
5
5
  """
6
6
  from __future__ import annotations
7
+ import os
7
8
  from dataclasses import dataclass, field
9
+ from typing import Any
8
10
 
9
11
 
10
12
  BUILT_IN_DEFAULTS: dict = {
11
13
  "vus": 1,
12
14
  "duration": "30s",
13
- "iterations": None,
15
+ "iterations": 1,
14
16
  "ramp_up": "0s",
15
17
  "timeout": "30s",
18
+ "workers": os.cpu_count() or 1,
16
19
  "mode": "monolith",
17
20
  "think_time": "none",
18
21
  "think_time_scale": 1.0,
@@ -36,9 +39,10 @@ class LoadTestConfig:
36
39
  # Load shape
37
40
  vus: int = 1
38
41
  duration: str = "30s"
39
- iterations: int | None = None
42
+ iterations: int = 1
40
43
  ramp_up: str = "0s"
41
44
  timeout: str = "30s"
45
+ workers: int = 1
42
46
 
43
47
  # Traffic
44
48
  mode: str = "monolith"
@@ -107,7 +111,7 @@ def from_args(args: object) -> LoadTestConfig:
107
111
 
108
112
  # For toml-sourced fields, use CLI value if provided (not None), else toml value,
109
113
  # else built-in default.
110
- def resolve(name: str) -> object:
114
+ def resolve(name: str) -> Any:
111
115
  cli_val = getattr(args, name, None)
112
116
  if cli_val is not None:
113
117
  return cli_val
@@ -128,6 +132,7 @@ def from_args(args: object) -> LoadTestConfig:
128
132
  iterations=resolve("iterations"),
129
133
  mode=resolve("mode"),
130
134
  vus=resolve("vus"),
135
+ workers=resolve("workers"),
131
136
  duration=resolve("duration"),
132
137
  ramp_up=resolve("ramp_up"),
133
138
  timeout=resolve("timeout"),
@@ -0,0 +1,343 @@
1
+ """asyncio VU pool: ramp-up, duration/iteration control, graceful shutdown.
2
+
3
+ core/ has zero knowledge of jac-scale internals.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import signal
9
+ import socket
10
+ import sys
11
+ from typing import TYPE_CHECKING
12
+
13
+ import aiohttp
14
+ from aiohttp.abc import AbstractResolver as _AbstractResolver
15
+
16
+ from jac_loadtest.config import parse_duration
17
+ from jac_loadtest.core.metrics import RequestResult, normalize_path
18
+
19
+ if TYPE_CHECKING:
20
+ from jac_loadtest.core.har_parser import HarEntry
21
+ from jac_loadtest.core.metrics import MetricsCollector
22
+ from jac_loadtest.config import LoadTestConfig
23
+ from jac_loadtest.bridge.topology import TopologyRouter
24
+ from jac_loadtest.bridge.auth import AuthProvider
25
+
26
+
27
+ class _PreResolvedResolver(_AbstractResolver):
28
+ """Resolver that returns a pre-resolved IP for known hostnames, falling back to
29
+ system DNS for anything else. Injected into each worker's TCPConnector so workers
30
+ never issue their own DNS lookups for the target host."""
31
+
32
+ def __init__(self, host_map: dict[str, str]) -> None:
33
+ self._host_map = host_map
34
+ self._fallback = aiohttp.ThreadedResolver()
35
+
36
+ async def resolve( # type: ignore[override]
37
+ self, hostname: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
38
+ ) -> list[dict]:
39
+ if hostname in self._host_map:
40
+ ip = self._host_map[hostname]
41
+ addr_family = socket.AF_INET6 if ":" in ip else socket.AF_INET
42
+ return [{"hostname": hostname, "host": ip, "port": port, "family": addr_family, "proto": 0, "flags": 0}]
43
+ return await self._fallback.resolve(hostname, port, family) # type: ignore[return-value]
44
+
45
+ async def close(self) -> None:
46
+ await self._fallback.close()
47
+
48
+
49
+ async def run_all_vus(
50
+ entries: list[HarEntry],
51
+ config: LoadTestConfig,
52
+ metrics: MetricsCollector,
53
+ topology: TopologyRouter | None = None,
54
+ auth_provider: AuthProvider | None = None,
55
+ vu_id_offset: int = 0,
56
+ pre_authed_tokens: dict[int, str] | None = None,
57
+ pre_resolved_hosts: dict[str, str] | None = None,
58
+ ) -> None:
59
+ """Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
60
+ stop_requested = asyncio.Event()
61
+ loop = asyncio.get_event_loop()
62
+
63
+ original_sigint = signal.getsignal(signal.SIGINT)
64
+
65
+ def _on_second_sigint(sig: int, frame: object) -> None:
66
+ sys.exit(130)
67
+
68
+ def _on_first_sigint(sig: int, frame: object) -> None:
69
+ stop_requested.set()
70
+ signal.signal(signal.SIGINT, _on_second_sigint)
71
+
72
+ signal.signal(signal.SIGINT, _on_first_sigint)
73
+
74
+ timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
75
+ ramp_up_seconds = parse_duration(config.ramp_up)
76
+
77
+ # Use pre-computed tokens when provided (multi-process path: auth ran in main process).
78
+ # Otherwise authenticate here (single-process path).
79
+ token_by_vu: dict[int, str] = {}
80
+ if pre_authed_tokens is not None:
81
+ token_by_vu = pre_authed_tokens
82
+ elif auth_provider is not None:
83
+ if not config.url:
84
+ raise ValueError("auth_provider requires --url to be set")
85
+ n_creds = len(auth_provider._credentials)
86
+ # Unique credential indices needed by this worker's VU slice.
87
+ cred_indices = {(vu_id_offset + i) % n_creds for i in range(config.vus)}
88
+ sem = asyncio.Semaphore(min(50, len(cred_indices)))
89
+
90
+ async def _do_auth(cred_idx: int) -> tuple[int, str]:
91
+ async with sem:
92
+ async with aiohttp.ClientSession(timeout=timeout) as auth_session:
93
+ if config.url is not None:
94
+ tok = await auth_provider.authenticate(cred_idx, auth_session, config.url)
95
+ return cred_idx, tok
96
+
97
+ auth_results: list[tuple[int, str]] = list(
98
+ await asyncio.gather(*[_do_auth(idx) for idx in cred_indices])
99
+ )
100
+ token_by_cred = dict(auth_results)
101
+ token_by_vu = {
102
+ vu_id_offset + i: token_by_cred[(vu_id_offset + i) % n_creds]
103
+ for i in range(config.vus)
104
+ }
105
+
106
+ tasks = [
107
+ asyncio.create_task(
108
+ _run_vu(
109
+ vu_id=vu_id_offset + i,
110
+ delay=(i / config.vus) * ramp_up_seconds if config.vus > 1 else 0.0,
111
+ entries=entries,
112
+ config=config,
113
+ metrics=metrics,
114
+ stop_requested=stop_requested,
115
+ loop=loop,
116
+ token=token_by_vu.get(vu_id_offset + i),
117
+ topology=topology,
118
+ pre_resolved_hosts=pre_resolved_hosts or {},
119
+ )
120
+ )
121
+ for i in range(config.vus)
122
+ ]
123
+
124
+ try:
125
+ await asyncio.gather(*tasks)
126
+ finally:
127
+ signal.signal(signal.SIGINT, original_sigint)
128
+
129
+
130
+ async def _run_vu(
131
+ vu_id: int,
132
+ delay: float,
133
+ entries: list[HarEntry],
134
+ config: LoadTestConfig,
135
+ metrics: MetricsCollector,
136
+ stop_requested: asyncio.Event,
137
+ loop: asyncio.AbstractEventLoop,
138
+ token: str | None = None,
139
+ topology: TopologyRouter | None = None,
140
+ pre_resolved_hosts: dict[str, str] | None = None,
141
+ ) -> None:
142
+ """Single virtual user: wait ramp delay, then replay HAR entries with a pre-fetched token."""
143
+ if delay > 0:
144
+ await asyncio.sleep(delay)
145
+
146
+ timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
147
+
148
+ iteration = 0
149
+ # Warn once per unrouted path within this VU to avoid spam across iterations.
150
+ _warned_unrouted: set[str] = set()
151
+
152
+ connector = (
153
+ aiohttp.TCPConnector(resolver=_PreResolvedResolver(pre_resolved_hosts))
154
+ if pre_resolved_hosts
155
+ else aiohttp.TCPConnector()
156
+ )
157
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
158
+ while not stop_requested.is_set():
159
+ if config.iterations is not None and iteration >= config.iterations:
160
+ break
161
+
162
+ for entry in entries:
163
+ if stop_requested.is_set():
164
+ break
165
+ # Skip the HAR-recorded login; auth is handled by pre-fetched token.
166
+ if entry.is_login and token is not None:
167
+ continue
168
+ result = await _send_request(
169
+ session=session,
170
+ entry=entry,
171
+ vu_id=vu_id,
172
+ config=config,
173
+ loop=loop,
174
+ token=token,
175
+ topology=topology,
176
+ )
177
+ if result is None:
178
+ from urllib.parse import urlparse as _up
179
+ path = _up(entry.url).path
180
+ if path not in _warned_unrouted:
181
+ print(
182
+ f"\n\033[33mWarning: no route for '{path}' — skipping. "
183
+ "Add a matching prefix to --services-map or set --url as fallback.\033[0m",
184
+ file=sys.stderr,
185
+ )
186
+ _warned_unrouted.add(path)
187
+ continue
188
+ metrics.record(result)
189
+ if config.think_time == "real" and entry.think_time_ms > 0:
190
+ await asyncio.sleep(
191
+ entry.think_time_ms / 1000.0 * config.think_time_scale
192
+ )
193
+
194
+ iteration += 1
195
+
196
+
197
+ async def _send_request(
198
+ session: aiohttp.ClientSession,
199
+ entry: HarEntry,
200
+ vu_id: int,
201
+ config: LoadTestConfig,
202
+ loop: asyncio.AbstractEventLoop,
203
+ token: str | None = None,
204
+ topology: TopologyRouter | None = None,
205
+ ) -> RequestResult | None:
206
+ """Send one HTTP request and return a RequestResult, or None if no route exists."""
207
+ headers = dict(entry.headers)
208
+ if token:
209
+ headers["Authorization"] = f"Bearer {token}"
210
+
211
+ if topology is not None:
212
+ try:
213
+ request_url, service_name = topology.resolve(entry.url)
214
+ except ValueError:
215
+ return None # caller warns and skips
216
+ else:
217
+ request_url, service_name = entry.url, "monolith"
218
+
219
+ import time as _time
220
+ endpoint = normalize_path(entry.url)
221
+ t0 = loop.time() # high-res clock for latency measurement only
222
+ ts = _time.time() # wall-clock for cross-process timestamp comparison
223
+
224
+ try:
225
+ async with session.request(
226
+ method=entry.method,
227
+ url=request_url,
228
+ headers=headers,
229
+ data=entry.body,
230
+ allow_redirects=False,
231
+ ) as resp:
232
+ body = await resp.read()
233
+ latency_ms = (loop.time() - t0) * 1000
234
+ return RequestResult(
235
+ endpoint=endpoint,
236
+ service=service_name,
237
+ status=resp.status,
238
+ latency_ms=latency_ms,
239
+ bytes_received=len(body),
240
+ timestamp=ts,
241
+ vu_id=vu_id,
242
+ error_type=None,
243
+ occurrence=entry.occurrence,
244
+ total_occurrences=entry.total_occurrences,
245
+ )
246
+
247
+ except asyncio.TimeoutError:
248
+ return RequestResult(
249
+ endpoint=endpoint,
250
+ service=service_name,
251
+ status=0,
252
+ latency_ms=parse_duration(config.timeout) * 1000,
253
+ bytes_received=0,
254
+ timestamp=ts,
255
+ vu_id=vu_id,
256
+ error_type="TIMEOUT",
257
+ occurrence=entry.occurrence,
258
+ total_occurrences=entry.total_occurrences,
259
+ )
260
+
261
+ except aiohttp.ClientConnectorDNSError:
262
+ return RequestResult(
263
+ endpoint=endpoint,
264
+ service=service_name,
265
+ status=0,
266
+ latency_ms=0.0,
267
+ bytes_received=0,
268
+ timestamp=ts,
269
+ vu_id=vu_id,
270
+ error_type="DNS_ERROR",
271
+ occurrence=entry.occurrence,
272
+ total_occurrences=entry.total_occurrences,
273
+ )
274
+
275
+ except aiohttp.ClientSSLError:
276
+ return RequestResult(
277
+ endpoint=endpoint,
278
+ service=service_name,
279
+ status=0,
280
+ latency_ms=0.0,
281
+ bytes_received=0,
282
+ timestamp=ts,
283
+ vu_id=vu_id,
284
+ error_type="SSL_ERROR",
285
+ occurrence=entry.occurrence,
286
+ total_occurrences=entry.total_occurrences,
287
+ )
288
+
289
+ except aiohttp.ClientConnectorError:
290
+ return RequestResult(
291
+ endpoint=endpoint,
292
+ service=service_name,
293
+ status=0,
294
+ latency_ms=0.0,
295
+ bytes_received=0,
296
+ timestamp=ts,
297
+ vu_id=vu_id,
298
+ error_type="CONNECTION_REFUSED",
299
+ occurrence=entry.occurrence,
300
+ total_occurrences=entry.total_occurrences,
301
+ )
302
+
303
+ except aiohttp.ServerDisconnectedError:
304
+ return RequestResult(
305
+ endpoint=endpoint,
306
+ service=service_name,
307
+ status=0,
308
+ latency_ms=(loop.time() - t0) * 1000,
309
+ bytes_received=0,
310
+ timestamp=ts,
311
+ vu_id=vu_id,
312
+ error_type="SERVER_DISCONNECTED",
313
+ occurrence=entry.occurrence,
314
+ total_occurrences=entry.total_occurrences,
315
+ )
316
+
317
+ except aiohttp.ClientOSError:
318
+ return RequestResult(
319
+ endpoint=endpoint,
320
+ service=service_name,
321
+ status=0,
322
+ latency_ms=(loop.time() - t0) * 1000,
323
+ bytes_received=0,
324
+ timestamp=ts,
325
+ vu_id=vu_id,
326
+ error_type="CONNECTION_RESET",
327
+ occurrence=entry.occurrence,
328
+ total_occurrences=entry.total_occurrences,
329
+ )
330
+
331
+ except Exception as e:
332
+ return RequestResult(
333
+ endpoint=endpoint,
334
+ service=service_name,
335
+ status=0,
336
+ latency_ms=(loop.time() - t0) * 1000,
337
+ bytes_received=0,
338
+ timestamp=ts,
339
+ vu_id=vu_id,
340
+ error_type=str(e).upper() or type(e).__name__.upper(),
341
+ occurrence=entry.occurrence,
342
+ total_occurrences=entry.total_occurrences,
343
+ )
@@ -49,6 +49,8 @@ class HarEntry:
49
49
  think_time_ms: float
50
50
  is_login: bool
51
51
  original_url: str
52
+ occurrence: int = 0
53
+ total_occurrences: int = 0
52
54
 
53
55
 
54
56
  def _origin(url: str) -> str:
@@ -81,6 +83,22 @@ def _is_unsupported_type(entry: dict) -> bool:
81
83
  return url.startswith(("ws://", "wss://"))
82
84
 
83
85
 
86
+ def _has_missing_body(method: str, raw_headers: list[dict], post_data: dict | None) -> bool:
87
+ """Return True if a request should have a body but none was captured in the HAR."""
88
+ if method.upper() not in ("POST", "PUT", "PATCH"):
89
+ return False
90
+ content_length = next(
91
+ (h["value"] for h in raw_headers if h.get("name", "").lower() == "content-length"),
92
+ "0",
93
+ )
94
+ try:
95
+ has_content = int(content_length) > 0
96
+ except ValueError:
97
+ has_content = False
98
+ body_text = (post_data or {}).get("text")
99
+ return has_content and not body_text
100
+
101
+
84
102
  def _has_cache_buster(url: str) -> bool:
85
103
  """Return True if the URL contains a stale cache-busting timestamp parameter."""
86
104
  qs = parse_qs(urlparse(url).query)
@@ -125,6 +143,7 @@ def parse_har(
125
143
  result: list[HarEntry] = []
126
144
  warned_unsupported = False
127
145
  warned_cache_buster = False
146
+ warned_missing_body = False
128
147
 
129
148
  for entry in raw_entries:
130
149
  req = entry["request"]
@@ -135,9 +154,9 @@ def parse_har(
135
154
  if _is_unsupported_type(entry):
136
155
  if not warned_unsupported:
137
156
  print(
138
- "Warning: HAR contains WebSocket, SSE, or non-API entries "
157
+ "\n\033[33mWarning: HAR contains WebSocket, SSE, or non-API entries "
139
158
  "(websocket, eventsource, document, etc.). "
140
- "These are skipped automatically.",
159
+ "These are skipped automatically.\033[0m",
141
160
  file=sys.stderr,
142
161
  )
143
162
  warned_unsupported = True
@@ -148,9 +167,9 @@ def parse_har(
148
167
  if _has_cache_buster(original_url):
149
168
  if not warned_cache_buster:
150
169
  print(
151
- "Warning: HAR contains URLs with cache-busting timestamp "
170
+ "\n\033[33mWarning: HAR contains URLs with cache-busting timestamp "
152
171
  "parameters (e.g. ?_=<timestamp>). These entries are skipped — "
153
- "the stale timestamp causes the server to reject the request.",
172
+ "the stale timestamp causes the server to reject the request.\033[0m",
154
173
  file=sys.stderr,
155
174
  )
156
175
  warned_cache_buster = True
@@ -164,7 +183,21 @@ def parse_har(
164
183
  else:
165
184
  rewritten_url = original_url # keep recorded URL; topology handles routing
166
185
 
167
- headers = _sanitize_headers(req.get("headers", []))
186
+ raw_headers = req.get("headers", [])
187
+
188
+ if _has_missing_body(req["method"], raw_headers, req.get("postData")):
189
+ if not warned_missing_body:
190
+ print(
191
+ "\n\033[33mWarning: HAR contains POST/PUT/PATCH entries where the request body "
192
+ "was not captured (postData missing despite non-zero Content-Length). "
193
+ "These entries are skipped — replaying them without a body would cause "
194
+ "422 errors. Re-record the HAR to capture the full request body.\033[0m",
195
+ file=sys.stderr,
196
+ )
197
+ warned_missing_body = True
198
+ continue
199
+
200
+ headers = _sanitize_headers(raw_headers)
168
201
 
169
202
  post_data = req.get("postData", {}) or {}
170
203
  body = post_data.get("text") or None
@@ -188,6 +221,19 @@ def parse_har(
188
221
  )
189
222
  )
190
223
 
224
+ # Count how many times each path appears so occurrence numbers can be assigned.
225
+ totals: dict[str, int] = {}
226
+ for entry in result:
227
+ path = urlparse(entry.url).path
228
+ totals[path] = totals.get(path, 0) + 1
229
+
230
+ seen: dict[str, int] = {}
231
+ for entry in result:
232
+ path = urlparse(entry.url).path
233
+ seen[path] = seen.get(path, 0) + 1
234
+ entry.occurrence = seen[path]
235
+ entry.total_occurrences = totals[path]
236
+
191
237
  return result
192
238
 
193
239
 
@@ -198,10 +244,10 @@ def _check_version(version: str) -> None:
198
244
  """Warn if the HAR version is outside the tested range."""
199
245
  if version not in _SUPPORTED_HAR_VERSIONS:
200
246
  print(
201
- f"Warning: HAR version '{version}' is not tested with this tool "
247
+ f"\n\033[33mWarning: HAR version '{version}' is not tested with this tool "
202
248
  f"(tested: {', '.join(sorted(_SUPPORTED_HAR_VERSIONS))}).\n"
203
249
  "Parsing will continue but results may be incomplete or incorrect.\n"
204
- "If the output looks wrong, check for a jac-loadtest update.",
250
+ "If the output looks wrong, check for a jac-loadtest update.\033[0m",
205
251
  file=sys.stderr,
206
252
  )
207
253
 
@@ -214,10 +260,10 @@ def _security_scan(entries: list[dict]) -> None:
214
260
  value = hdr.get("value", "")
215
261
  if name in ("authorization", "cookie") and value:
216
262
  print(
217
- "Warning: HAR file contains Authorization/Cookie headers from the "
263
+ "\n\033[33mWarning: HAR file contains Authorization/Cookie headers from the "
218
264
  "recording session.\nThese headers are stripped before replay, but "
219
265
  "the file itself contains sensitive data.\n"
220
- "Do not commit this HAR file to version control.",
266
+ "Do not commit this HAR file to version control.\033[0m",
221
267
  file=sys.stderr,
222
268
  )
223
269
  return