jac-loadtest 0.1.0__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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: jac-loadtest
3
+ Version: 0.1.0
4
+ Summary: HAR-based load testing CLI for jac-scale applications
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: jaclang==0.15.2
8
+ Requires-Dist: jac-scale==0.2.16
9
+ Requires-Dist: rich>=13.0.0
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0; extra == "test"
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
13
+ Requires-Dist: pytest-mock>=3.12; extra == "test"
14
+ Requires-Dist: aiohttp[speedups]>=3.9; extra == "test"
15
+
16
+ # jac-loadtest
17
+
18
+ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci/tree/main/jac-scale) applications. Capture real browser traffic via Chrome DevTools, export it as a `.har` file, and replay it under load — no scripting required.
19
+
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
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Minimal: 1 VU, 30s
26
+ jac loadtest recording.har --url http://localhost:8000
27
+
28
+ # 50 VUs with 10s ramp-up
29
+ jac loadtest recording.har --url http://localhost:8000 --vus 50 --ramp-up 10s --duration 60s
30
+
31
+ # Authenticated test with per-VU credentials
32
+ jac loadtest recording.har --url http://localhost:8000 --vus 20 --credentials-file creds.csv
33
+
34
+ # CI-friendly with thresholds
35
+ jac loadtest recording.har --url http://localhost:8000 \
36
+ --vus 10 --duration 30s --fail-on-p95 500 --fail-on-error-rate 1
37
+ ```
38
+
39
+ ## Developer Setup
40
+
41
+ ```bash
42
+ # 1. Create and activate a conda env (Python 3.12 required)
43
+ conda create -n load python=3.12
44
+ conda activate load
45
+
46
+ # 2. Install the package in editable mode (runtime deps only)
47
+ pip install -e .
48
+
49
+ # 3. Also install test dependencies (when running tests)
50
+ pip install -e ".[test]"
51
+
52
+ # 4. Verify the command is registered
53
+ jac loadtest --help
54
+ ```
55
+
56
+ ## Project Layout
57
+
58
+ ```
59
+ jac_loadtest/
60
+ ├── plugin.py — registers `jac loadtest` via jaclang entry-points
61
+ ├── cli.py — argument wiring; JacMetaImporter bootstrap
62
+ ├── config.py — LoadTestConfig dataclass (three-layer resolution)
63
+ ├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
64
+ ├── bridge/ — jac-scale-aware adapters (auth, topology)
65
+ └── output/ — console, JSON, HTML reporters
66
+ ```
67
+
68
+ The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
69
+
70
+ ## HAR Compatibility
71
+
72
+ Tested with HAR **1.1** and **1.2** (the format exported by Chrome DevTools, Firefox, Postman, and Insomnia). Files from other versions are parsed with a warning — open an issue if something breaks.
73
+
74
+ ## Documentation
75
+
76
+ - [Architecture](docs/ARCHITECTURE.md) — module map, data flow, design decisions
77
+ - [Roadmap](docs/ROADMAP.md) — delivery phases and exit criteria
78
+ - [Verification](docs/VERIFICATION.md) — phase-by-phase manual and automated verification checklists
@@ -0,0 +1,63 @@
1
+ # jac-loadtest
2
+
3
+ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci/tree/main/jac-scale) applications. Capture real browser traffic via Chrome DevTools, export it as a `.har` file, and replay it under load — no scripting required.
4
+
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
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # Minimal: 1 VU, 30s
11
+ jac loadtest recording.har --url http://localhost:8000
12
+
13
+ # 50 VUs with 10s ramp-up
14
+ jac loadtest recording.har --url http://localhost:8000 --vus 50 --ramp-up 10s --duration 60s
15
+
16
+ # Authenticated test with per-VU credentials
17
+ jac loadtest recording.har --url http://localhost:8000 --vus 20 --credentials-file creds.csv
18
+
19
+ # CI-friendly with thresholds
20
+ jac loadtest recording.har --url http://localhost:8000 \
21
+ --vus 10 --duration 30s --fail-on-p95 500 --fail-on-error-rate 1
22
+ ```
23
+
24
+ ## Developer Setup
25
+
26
+ ```bash
27
+ # 1. Create and activate a conda env (Python 3.12 required)
28
+ conda create -n load python=3.12
29
+ conda activate load
30
+
31
+ # 2. Install the package in editable mode (runtime deps only)
32
+ pip install -e .
33
+
34
+ # 3. Also install test dependencies (when running tests)
35
+ pip install -e ".[test]"
36
+
37
+ # 4. Verify the command is registered
38
+ jac loadtest --help
39
+ ```
40
+
41
+ ## Project Layout
42
+
43
+ ```
44
+ jac_loadtest/
45
+ ├── plugin.py — registers `jac loadtest` via jaclang entry-points
46
+ ├── cli.py — argument wiring; JacMetaImporter bootstrap
47
+ ├── config.py — LoadTestConfig dataclass (three-layer resolution)
48
+ ├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
49
+ ├── bridge/ — jac-scale-aware adapters (auth, topology)
50
+ └── output/ — console, JSON, HTML reporters
51
+ ```
52
+
53
+ The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
54
+
55
+ ## HAR Compatibility
56
+
57
+ Tested with HAR **1.1** and **1.2** (the format exported by Chrome DevTools, Firefox, Postman, and Insomnia). Files from other versions are parsed with a warning — open an issue if something breaks.
58
+
59
+ ## Documentation
60
+
61
+ - [Architecture](docs/ARCHITECTURE.md) — module map, data flow, design decisions
62
+ - [Roadmap](docs/ROADMAP.md) — delivery phases and exit criteria
63
+ - [Verification](docs/VERIFICATION.md) — phase-by-phase manual and automated verification checklists
File without changes
File without changes
@@ -0,0 +1,15 @@
1
+ """jac-scale auth: login via /user/login, per-VU JWT injection.
2
+
3
+ Implemented in Phase 2.
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ async def get_token(
9
+ session: object,
10
+ base_url: str,
11
+ username: str,
12
+ password: str,
13
+ login_path: str = "/user/login",
14
+ ) -> str:
15
+ raise NotImplementedError("Auth module is implemented in Phase 2.")
@@ -0,0 +1,24 @@
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.
4
+ Implemented in Phase 3.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def build_routing_table(
10
+ mode: str,
11
+ base_url: str | None = None,
12
+ services_map_json: str | None = None,
13
+ ) -> dict[str, str]:
14
+ raise NotImplementedError("Topology module is implemented in Phase 3.")
15
+
16
+
17
+ def resolve_url(path: str, routing_table: dict[str, str], fallback_url: str | None) -> str:
18
+ """Longest-prefix match: mirrors jac-scale ServiceRegistry.match_route()."""
19
+ for prefix in sorted(routing_table, key=len, reverse=True):
20
+ if path.startswith(prefix):
21
+ return routing_table[prefix]
22
+ if fallback_url:
23
+ return fallback_url
24
+ raise ValueError(f"No route for path '{path}' and no --url fallback provided.")
@@ -0,0 +1,56 @@
1
+ # JacMetaImporter must be registered before any jac_scale import.
2
+ # jac-scale's microservice modules are compiled Jac; without this the import fails.
3
+ from jaclang.meta_importer import JacMetaImporter
4
+ import sys
5
+
6
+ if not any(isinstance(f, JacMetaImporter) for f in sys.meta_path):
7
+ sys.meta_path.insert(0, JacMetaImporter())
8
+
9
+
10
+ def run(args: object) -> None:
11
+ import asyncio
12
+ import time
13
+
14
+ from jac_loadtest.config import from_args
15
+ from jac_loadtest.core.har_parser import parse_har
16
+ from jac_loadtest.core.engine import run_all_vus
17
+ from jac_loadtest.core.metrics import MetricsCollector
18
+ from jac_loadtest.output.reporter import render_console
19
+
20
+ config = from_args(args)
21
+
22
+ if not config.url:
23
+ print("Error: --url is required", file=sys.stderr)
24
+ sys.exit(2)
25
+
26
+ if not config.har_file:
27
+ print("Error: har_file positional argument is required", file=sys.stderr)
28
+ sys.exit(2)
29
+
30
+ try:
31
+ entries = parse_har(
32
+ config.har_file,
33
+ target_url=config.url,
34
+ include_static=config.include_static,
35
+ login_path=config.login_path,
36
+ )
37
+ except (FileNotFoundError, ValueError) as exc:
38
+ print(f"Error: {exc}", file=sys.stderr)
39
+ sys.exit(2)
40
+
41
+ if not entries:
42
+ print(
43
+ "Error: no API entries found in HAR file after filtering. "
44
+ "Use --include-static to include static assets.",
45
+ file=sys.stderr,
46
+ )
47
+ sys.exit(2)
48
+
49
+ metrics = MetricsCollector(max_samples=config.max_samples)
50
+ t_start = time.time()
51
+
52
+ asyncio.run(run_all_vus(entries, config, metrics))
53
+
54
+ duration_s = time.time() - t_start
55
+ stats = metrics.compute_endpoint_stats(duration_s)
56
+ render_console(stats, config)
@@ -0,0 +1,123 @@
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
+
9
+
10
+ BUILT_IN_DEFAULTS: dict = {
11
+ "vus": 1,
12
+ "duration": "30s",
13
+ "ramp_up": "0s",
14
+ "timeout": "30s",
15
+ "mode": "monolith",
16
+ "think_time": "none",
17
+ "think_time_scale": 1.0,
18
+ "rps": 0,
19
+ "include_static": False,
20
+ "login_path": "/user/login",
21
+ "fail_on_error_rate": None,
22
+ "fail_on_p95": None,
23
+ "fail_on_p99": None,
24
+ "abort_on_fail": False,
25
+ "threshold_start_delay": "0s",
26
+ "report_format": "console",
27
+ "max_samples": 1_000_000,
28
+ "csrf": False,
29
+ "debug": False,
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class LoadTestConfig:
35
+ # Load shape
36
+ vus: int = 1
37
+ duration: str = "30s"
38
+ iterations: int | None = None
39
+ ramp_up: str = "0s"
40
+ timeout: str = "30s"
41
+
42
+ # Traffic
43
+ mode: str = "monolith"
44
+ think_time: str = "none"
45
+ think_time_scale: float = 1.0
46
+ rps: int = 0
47
+ include_static: bool = False
48
+
49
+ # Auth
50
+ login_path: str = "/user/login"
51
+ csrf: bool = False
52
+
53
+ # CI thresholds
54
+ fail_on_error_rate: float | None = None
55
+ fail_on_p95: float | None = None
56
+ fail_on_p99: float | None = None
57
+ abort_on_fail: bool = False
58
+ threshold_start_delay: str = "0s"
59
+
60
+ # Output
61
+ report_format: str = "console"
62
+ max_samples: int = 1_000_000
63
+ debug: bool = False
64
+
65
+ # CLI-only — not sourced from jac.toml (environment-specific or security-sensitive)
66
+ har_file: str = ""
67
+ url: str | None = None
68
+ username: str | None = None
69
+ password: str | None = None
70
+ credentials_file: str | None = None
71
+ services_map: str | None = None
72
+ report_out: str | None = None
73
+
74
+
75
+ def parse_duration(s: str) -> float:
76
+ """Convert a duration string ('30s', '2m', '1h') to seconds."""
77
+ s = s.strip()
78
+ if s.endswith("h"):
79
+ return float(s[:-1]) * 3600
80
+ if s.endswith("m"):
81
+ return float(s[:-1]) * 60
82
+ if s.endswith("s"):
83
+ return float(s[:-1])
84
+ return float(s)
85
+
86
+
87
+ def from_args(args: object) -> LoadTestConfig:
88
+ """Build LoadTestConfig by applying CLI args on top of built-in defaults.
89
+
90
+ Phase 2 will insert a jac.toml layer between defaults and CLI args.
91
+ """
92
+ def get(name: str, default=None):
93
+ return getattr(args, name, default)
94
+
95
+ return LoadTestConfig(
96
+ har_file=get("har_file", ""),
97
+ url=get("url"),
98
+ mode=get("mode", BUILT_IN_DEFAULTS["mode"]),
99
+ vus=get("vus", BUILT_IN_DEFAULTS["vus"]),
100
+ duration=get("duration", BUILT_IN_DEFAULTS["duration"]),
101
+ iterations=get("iterations"),
102
+ ramp_up=get("ramp_up", BUILT_IN_DEFAULTS["ramp_up"]),
103
+ timeout=get("timeout", BUILT_IN_DEFAULTS["timeout"]),
104
+ think_time=get("think_time", BUILT_IN_DEFAULTS["think_time"]),
105
+ think_time_scale=get("think_time_scale", BUILT_IN_DEFAULTS["think_time_scale"]),
106
+ username=get("username"),
107
+ password=get("password"),
108
+ credentials_file=get("credentials_file"),
109
+ login_path=get("login_path", BUILT_IN_DEFAULTS["login_path"]),
110
+ include_static=get("include_static", BUILT_IN_DEFAULTS["include_static"]),
111
+ rps=get("rps", BUILT_IN_DEFAULTS["rps"]),
112
+ max_samples=get("max_samples", BUILT_IN_DEFAULTS["max_samples"]),
113
+ services_map=get("services_map"),
114
+ csrf=get("csrf", BUILT_IN_DEFAULTS["csrf"]),
115
+ fail_on_error_rate=get("fail_on_error_rate"),
116
+ fail_on_p95=get("fail_on_p95"),
117
+ fail_on_p99=get("fail_on_p99"),
118
+ abort_on_fail=get("abort_on_fail", BUILT_IN_DEFAULTS["abort_on_fail"]),
119
+ threshold_start_delay=get("threshold_start_delay", BUILT_IN_DEFAULTS["threshold_start_delay"]),
120
+ report_format=get("report_format", BUILT_IN_DEFAULTS["report_format"]),
121
+ report_out=get("report_out"),
122
+ debug=get("debug", BUILT_IN_DEFAULTS["debug"]),
123
+ )
File without changes
@@ -0,0 +1,162 @@
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 sys
10
+ from typing import TYPE_CHECKING
11
+
12
+ import aiohttp
13
+
14
+ from jac_loadtest.config import parse_duration
15
+ from jac_loadtest.core.metrics import RequestResult, normalize_path
16
+
17
+ if TYPE_CHECKING:
18
+ from jac_loadtest.core.har_parser import HarEntry
19
+ from jac_loadtest.core.metrics import MetricsCollector
20
+ from jac_loadtest.config import LoadTestConfig
21
+
22
+
23
+ async def run_all_vus(
24
+ entries: list[HarEntry],
25
+ config: LoadTestConfig,
26
+ metrics: MetricsCollector,
27
+ topology: object | None = None,
28
+ auth_provider: object | None = None,
29
+ ) -> None:
30
+ """Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
31
+ stop_requested = asyncio.Event()
32
+ loop = asyncio.get_event_loop()
33
+
34
+ original_sigint = signal.getsignal(signal.SIGINT)
35
+
36
+ def _on_second_sigint(sig: int, frame: object) -> None:
37
+ sys.exit(130)
38
+
39
+ def _on_first_sigint(sig: int, frame: object) -> None:
40
+ stop_requested.set()
41
+ signal.signal(signal.SIGINT, _on_second_sigint)
42
+
43
+ signal.signal(signal.SIGINT, _on_first_sigint)
44
+
45
+ ramp_up_seconds = parse_duration(config.ramp_up)
46
+
47
+ tasks = [
48
+ asyncio.create_task(
49
+ _run_vu(
50
+ vu_id=i,
51
+ delay=(i / config.vus) * ramp_up_seconds if config.vus > 1 else 0.0,
52
+ entries=entries,
53
+ config=config,
54
+ metrics=metrics,
55
+ stop_requested=stop_requested,
56
+ loop=loop,
57
+ )
58
+ )
59
+ for i in range(config.vus)
60
+ ]
61
+
62
+ try:
63
+ await asyncio.gather(*tasks)
64
+ finally:
65
+ signal.signal(signal.SIGINT, original_sigint)
66
+
67
+
68
+ async def _run_vu(
69
+ vu_id: int,
70
+ delay: float,
71
+ entries: list[HarEntry],
72
+ config: LoadTestConfig,
73
+ metrics: MetricsCollector,
74
+ stop_requested: asyncio.Event,
75
+ loop: asyncio.AbstractEventLoop,
76
+ ) -> None:
77
+ """Single virtual user: wait ramp delay, then replay HAR entries repeatedly."""
78
+ if delay > 0:
79
+ await asyncio.sleep(delay)
80
+
81
+ timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
82
+ duration_seconds = parse_duration(config.duration)
83
+ t_start = loop.time()
84
+ iteration = 0
85
+
86
+ async with aiohttp.ClientSession(timeout=timeout) as session:
87
+ while not stop_requested.is_set():
88
+ if loop.time() - t_start >= duration_seconds:
89
+ break
90
+ if config.iterations is not None and iteration >= config.iterations:
91
+ break
92
+
93
+ for entry in entries:
94
+ if stop_requested.is_set():
95
+ break
96
+ result = await _send_request(
97
+ session=session,
98
+ entry=entry,
99
+ vu_id=vu_id,
100
+ config=config,
101
+ loop=loop,
102
+ )
103
+ metrics.record(result)
104
+
105
+ iteration += 1
106
+
107
+
108
+ async def _send_request(
109
+ session: aiohttp.ClientSession,
110
+ entry: HarEntry,
111
+ vu_id: int,
112
+ config: LoadTestConfig,
113
+ loop: asyncio.AbstractEventLoop,
114
+ ) -> RequestResult:
115
+ """Send one HTTP request and return a RequestResult."""
116
+ headers = dict(entry.headers)
117
+ t0 = loop.time()
118
+
119
+ try:
120
+ async with session.request(
121
+ method=entry.method,
122
+ url=entry.url,
123
+ headers=headers,
124
+ data=entry.body,
125
+ allow_redirects=False,
126
+ ) as resp:
127
+ body = await resp.read()
128
+ latency_ms = (loop.time() - t0) * 1000
129
+ return RequestResult(
130
+ endpoint=normalize_path(entry.url),
131
+ service="monolith",
132
+ status=resp.status,
133
+ latency_ms=latency_ms,
134
+ bytes_received=len(body),
135
+ timestamp=t0,
136
+ vu_id=vu_id,
137
+ error_type=None,
138
+ )
139
+
140
+ except asyncio.TimeoutError:
141
+ return RequestResult(
142
+ endpoint=normalize_path(entry.url),
143
+ service="monolith",
144
+ status=0,
145
+ latency_ms=parse_duration(config.timeout) * 1000,
146
+ bytes_received=0,
147
+ timestamp=t0,
148
+ vu_id=vu_id,
149
+ error_type="TIMEOUT",
150
+ )
151
+
152
+ except aiohttp.ClientConnectorError:
153
+ return RequestResult(
154
+ endpoint=normalize_path(entry.url),
155
+ service="monolith",
156
+ status=0,
157
+ latency_ms=0.0,
158
+ bytes_received=0,
159
+ timestamp=t0,
160
+ vu_id=vu_id,
161
+ error_type="CONNECTION_REFUSED",
162
+ )
@@ -0,0 +1,161 @@
1
+ """Parse HAR 1.2 files, filter non-API entries, and rewrite URLs.
2
+
3
+ core/ has zero knowledge of jac-scale internals.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from urllib.parse import urlparse, urlunparse
11
+
12
+
13
+ _SKIP_MIME_PREFIXES = (
14
+ "image/",
15
+ "font/",
16
+ "text/css",
17
+ "application/javascript",
18
+ "text/javascript",
19
+ "application/wasm",
20
+ )
21
+
22
+ _STRIP_HEADERS = {"authorization", "cookie", "host", "content-length"}
23
+
24
+
25
+ @dataclass
26
+ class HarEntry:
27
+ method: str
28
+ url: str
29
+ headers: dict[str, str]
30
+ body: str | None
31
+ body_mime: str | None
32
+ think_time_ms: float
33
+ is_login: bool
34
+ original_url: str
35
+
36
+
37
+ def _origin(url: str) -> str:
38
+ """Return scheme://host:port (no path) from a URL."""
39
+ p = urlparse(url)
40
+ return urlunparse((p.scheme, p.netloc, "", "", "", ""))
41
+
42
+
43
+ def _rewrite_url(original: str, recorded_origin: str, target_url: str) -> str:
44
+ """Replace recorded origin with target_url, preserving path and query."""
45
+ p = urlparse(original)
46
+ t = urlparse(target_url)
47
+ rewritten = urlunparse((t.scheme, t.netloc, p.path, p.params, p.query, ""))
48
+ return rewritten
49
+
50
+
51
+ def _is_static(mime: str) -> bool:
52
+ if not mime:
53
+ return False
54
+ mime_lower = mime.lower().split(";")[0].strip()
55
+ return any(mime_lower.startswith(prefix) for prefix in _SKIP_MIME_PREFIXES)
56
+
57
+
58
+ def parse_har(
59
+ har_path: str,
60
+ target_url: str,
61
+ include_static: bool = False,
62
+ login_path: str = "/user/login",
63
+ ) -> list[HarEntry]:
64
+ """Parse a HAR 1.2 file and return filtered, URL-rewritten HarEntry objects."""
65
+ with open(har_path, encoding="utf-8") as f:
66
+ data = json.load(f)
67
+
68
+ if "log" not in data:
69
+ raise ValueError("Malformed HAR file: missing 'log' key")
70
+
71
+ _check_version(data["log"].get("version", "unknown"))
72
+
73
+ raw_entries = data["log"].get("entries", [])
74
+
75
+ if not raw_entries:
76
+ return []
77
+
78
+ recorded_origin = _origin(raw_entries[0]["request"]["url"])
79
+
80
+ # Security scan — warn once if any auth headers found
81
+ _security_scan(raw_entries)
82
+
83
+ result: list[HarEntry] = []
84
+ for entry in raw_entries:
85
+ req = entry["request"]
86
+ resp = entry.get("response", {})
87
+ content = resp.get("content", {})
88
+ mime = content.get("mimeType", "")
89
+
90
+ if not include_static and _is_static(mime):
91
+ continue
92
+
93
+ original_url = req["url"]
94
+ rewritten_url = _rewrite_url(original_url, recorded_origin, target_url)
95
+
96
+ headers = _sanitize_headers(req.get("headers", []))
97
+
98
+ post_data = req.get("postData", {}) or {}
99
+ body = post_data.get("text") or None
100
+ body_mime = post_data.get("mimeType") or None
101
+
102
+ timings = entry.get("timings", {})
103
+ think_time_ms = float(timings.get("wait", 0.0))
104
+
105
+ is_login = urlparse(original_url).path == login_path
106
+
107
+ result.append(
108
+ HarEntry(
109
+ method=req["method"].upper(),
110
+ url=rewritten_url,
111
+ headers=headers,
112
+ body=body,
113
+ body_mime=body_mime,
114
+ think_time_ms=think_time_ms,
115
+ is_login=is_login,
116
+ original_url=original_url,
117
+ )
118
+ )
119
+
120
+ return result
121
+
122
+
123
+ _SUPPORTED_HAR_VERSIONS = {"1.1", "1.2"}
124
+
125
+
126
+ def _check_version(version: str) -> None:
127
+ """Warn if the HAR version is outside the tested range."""
128
+ if version not in _SUPPORTED_HAR_VERSIONS:
129
+ print(
130
+ f"Warning: HAR version '{version}' is not tested with this tool "
131
+ f"(tested: {', '.join(sorted(_SUPPORTED_HAR_VERSIONS))}).\n"
132
+ "Parsing will continue but results may be incomplete or incorrect.\n"
133
+ "If the output looks wrong, check for a jac-loadtest update.",
134
+ file=sys.stderr,
135
+ )
136
+
137
+
138
+ def _security_scan(entries: list[dict]) -> None:
139
+ """Emit a stderr warning if any HAR entry contains auth/cookie headers."""
140
+ for entry in entries:
141
+ for hdr in entry.get("request", {}).get("headers", []):
142
+ name = hdr.get("name", "").lower()
143
+ value = hdr.get("value", "")
144
+ if name in ("authorization", "cookie") and value:
145
+ print(
146
+ "Warning: HAR file contains Authorization/Cookie headers from the "
147
+ "recording session.\nThese headers are stripped before replay, but "
148
+ "the file itself contains sensitive data.\n"
149
+ "Do not commit this HAR file to version control.",
150
+ file=sys.stderr,
151
+ )
152
+ return
153
+
154
+
155
+ def _sanitize_headers(raw_headers: list[dict]) -> dict[str, str]:
156
+ """Strip session-specific headers; return clean dict."""
157
+ return {
158
+ h["name"]: h["value"]
159
+ for h in raw_headers
160
+ if h.get("name", "").lower() not in _STRIP_HEADERS
161
+ }
@@ -0,0 +1,161 @@
1
+ """Per-request recording, latency histograms, percentile calculation.
2
+
3
+ Three-layer storage (Phase 4):
4
+ Layer 1 — total_count (always accurate RPS)
5
+ Layer 2 — deque(maxlen=max_samples) of RequestResult (percentiles)
6
+ Layer 3 — list[StatsSnapshot] every 5s (time-series charts)
7
+
8
+ Phase 0: dataclasses only.
9
+ """
10
+ from __future__ import annotations
11
+ import math
12
+ from dataclasses import dataclass, field
13
+ from collections import deque
14
+
15
+
16
+ @dataclass
17
+ class RequestResult:
18
+ endpoint: str
19
+ service: str
20
+ status: int
21
+ latency_ms: float
22
+ bytes_received: int
23
+ timestamp: float
24
+ vu_id: int
25
+ error_type: str | None # None | "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_ERROR" | "SSL_ERROR"
26
+
27
+
28
+ @dataclass
29
+ class EndpointStats:
30
+ endpoint: str
31
+ service: str
32
+ total_requests: int
33
+ success_count: int
34
+ error_count: int
35
+ success_rate_pct: float
36
+ min_ms: float
37
+ max_ms: float
38
+ mean_ms: float
39
+ p50_ms: float
40
+ p95_ms: float
41
+ p99_ms: float
42
+ rps: float
43
+ error_breakdown: dict[str, int] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class StatsSnapshot:
48
+ timestamp: float
49
+ p50_ms: float
50
+ p95_ms: float
51
+ p99_ms: float
52
+ rps: float
53
+ error_rate_pct: float
54
+
55
+
56
+ def percentile(latencies: list[float], p: float) -> float:
57
+ if not latencies:
58
+ return 0.0
59
+ sorted_l = sorted(latencies)
60
+ idx = int(math.ceil(p / 100.0 * len(sorted_l))) - 1
61
+ return sorted_l[max(0, idx)]
62
+
63
+
64
+ def normalize_path(url: str) -> str:
65
+ """Replace UUID and integer path segments with {id}, preserving full URL."""
66
+ import re
67
+ from urllib.parse import urlparse, urlunparse
68
+ parsed = urlparse(url)
69
+ segments = parsed.path.split("/")
70
+ normalized = []
71
+ for seg in segments:
72
+ if re.fullmatch(r"\d+", seg):
73
+ normalized.append("{id}")
74
+ elif re.fullmatch(r"[0-9a-f\-]{32,36}", seg):
75
+ normalized.append("{id}")
76
+ else:
77
+ normalized.append(seg)
78
+ normalized_path = "/".join(normalized)
79
+ return urlunparse((parsed.scheme, parsed.netloc, normalized_path, parsed.params, parsed.query, ""))
80
+
81
+
82
+ class MetricsCollector:
83
+ def __init__(self, max_samples: int = 1_000_000) -> None:
84
+ self.total_count: int = 0
85
+ self._samples: deque[RequestResult] = deque(maxlen=max_samples)
86
+ self._snapshots: list[StatsSnapshot] = []
87
+
88
+ def record(self, result: RequestResult) -> None:
89
+ self.total_count += 1
90
+ self._samples.append(result)
91
+
92
+ def compute_endpoint_stats(self, duration_seconds: float) -> list[EndpointStats]:
93
+ """Aggregate per-endpoint stats from collected samples."""
94
+ groups: dict[str, list[RequestResult]] = {}
95
+ for result in self._samples:
96
+ groups.setdefault(result.endpoint, []).append(result)
97
+
98
+ stats: list[EndpointStats] = []
99
+ safe_duration = max(duration_seconds, 0.001)
100
+
101
+ for endpoint, results in groups.items():
102
+ latencies = [r.latency_ms for r in results]
103
+ total = len(results)
104
+ success_count = sum(
105
+ 1 for r in results if r.error_type is None and 200 <= r.status < 300
106
+ )
107
+ error_count = total - success_count
108
+ success_rate = (success_count / total * 100.0) if total else 0.0
109
+
110
+ error_breakdown: dict[str, int] = {}
111
+ for r in results:
112
+ if r.error_type is not None:
113
+ key = r.error_type
114
+ elif not (200 <= r.status < 300):
115
+ key = str(r.status)
116
+ else:
117
+ continue
118
+ error_breakdown[key] = error_breakdown.get(key, 0) + 1
119
+
120
+ service = results[0].service if results else "monolith"
121
+
122
+ stats.append(
123
+ EndpointStats(
124
+ endpoint=endpoint,
125
+ service=service,
126
+ total_requests=total,
127
+ success_count=success_count,
128
+ error_count=error_count,
129
+ success_rate_pct=round(success_rate, 1),
130
+ min_ms=min(latencies) if latencies else 0.0,
131
+ max_ms=max(latencies) if latencies else 0.0,
132
+ mean_ms=sum(latencies) / len(latencies) if latencies else 0.0,
133
+ p50_ms=percentile(latencies, 50),
134
+ p95_ms=percentile(latencies, 95),
135
+ p99_ms=percentile(latencies, 99),
136
+ rps=self.total_count / safe_duration,
137
+ error_breakdown=error_breakdown,
138
+ )
139
+ )
140
+
141
+ return stats
142
+
143
+ def flush_snapshot(self, timestamp: float, duration_seconds: float) -> None:
144
+ """Record a 5-second interval snapshot (for time-series charts)."""
145
+ latencies = [r.latency_ms for r in self._samples]
146
+ safe_duration = max(duration_seconds, 0.001)
147
+ total = len(self._samples)
148
+ error_count = sum(
149
+ 1 for r in self._samples
150
+ if r.error_type is not None or not (200 <= r.status < 300)
151
+ )
152
+ self._snapshots.append(
153
+ StatsSnapshot(
154
+ timestamp=timestamp,
155
+ p50_ms=percentile(latencies, 50),
156
+ p95_ms=percentile(latencies, 95),
157
+ p99_ms=percentile(latencies, 99),
158
+ rps=self.total_count / safe_duration,
159
+ error_rate_pct=(error_count / total * 100.0) if total else 0.0,
160
+ )
161
+ )
File without changes
@@ -0,0 +1,91 @@
1
+ """Console (Rich), JSON, and HTML report rendering.
2
+
3
+ stdout: machine-readable output (json).
4
+ stderr: all human-readable output (console table, progress bar, warnings).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from jac_loadtest.core.metrics import EndpointStats
12
+ from jac_loadtest.config import LoadTestConfig
13
+
14
+
15
+ def render_console(stats: list[EndpointStats], config: LoadTestConfig) -> None:
16
+ """Print a Rich summary table to stderr."""
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich import box
20
+ from jac_loadtest.config import parse_duration
21
+
22
+ console = Console(stderr=True, highlight=False)
23
+
24
+ table = Table(box=box.SIMPLE_HEAVY, show_footer=False)
25
+ table.add_column("Endpoint", style="cyan", no_wrap=True)
26
+ table.add_column("Reqs", justify="right")
27
+ table.add_column("OK%", justify="right")
28
+ table.add_column("p50", justify="right")
29
+ table.add_column("p95", justify="right")
30
+ table.add_column("p99", justify="right")
31
+ table.add_column("RPS", justify="right")
32
+ table.add_column("Errs", justify="right")
33
+
34
+ for s in stats:
35
+ table.add_row(
36
+ s.endpoint,
37
+ str(s.total_requests),
38
+ f"{s.success_rate_pct:.1f}",
39
+ f"{s.p50_ms:.0f}ms",
40
+ f"{s.p95_ms:.0f}ms",
41
+ f"{s.p99_ms:.0f}ms",
42
+ f"{s.rps:.1f}",
43
+ str(s.error_count),
44
+ )
45
+
46
+ # TOTAL footer row aggregated across all endpoints
47
+ if stats:
48
+ total_reqs = sum(s.total_requests for s in stats)
49
+ total_success = sum(s.success_count for s in stats)
50
+ total_errors = sum(s.error_count for s in stats)
51
+ overall_ok_pct = (total_success / total_reqs * 100.0) if total_reqs else 0.0
52
+
53
+ all_latencies: list[float] = []
54
+ for s in stats:
55
+ # Approximate from p50/p95/p99 — real latencies live in MetricsCollector.
56
+ # For the TOTAL row we compute a weighted mean of percentile values.
57
+ all_latencies.extend([s.p50_ms] * s.total_requests)
58
+
59
+ from jac_loadtest.core.metrics import percentile as pct
60
+ all_p50 = pct(all_latencies, 50)
61
+ all_p95 = pct(all_latencies, 95)
62
+ all_p99 = pct(all_latencies, 99)
63
+ total_rps = sum(s.rps for s in stats)
64
+
65
+ table.add_section()
66
+ table.add_row(
67
+ "[bold]TOTAL[/bold]",
68
+ f"[bold]{total_reqs}[/bold]",
69
+ f"[bold]{overall_ok_pct:.1f}[/bold]",
70
+ f"[bold]{all_p50:.0f}ms[/bold]",
71
+ f"[bold]{all_p95:.0f}ms[/bold]",
72
+ f"[bold]{all_p99:.0f}ms[/bold]",
73
+ f"[bold]{total_rps:.1f}[/bold]",
74
+ f"[bold]{total_errors}[/bold]",
75
+ )
76
+
77
+ console.print(table)
78
+
79
+ duration_s = parse_duration(config.duration)
80
+ console.print(
81
+ f"Duration: {duration_s:.0f}s VUs: {config.vus} "
82
+ f"Ramp-up: {config.ramp_up} Mode: {config.mode}"
83
+ )
84
+
85
+
86
+ def render_json(stats: list[EndpointStats], config: LoadTestConfig) -> str:
87
+ raise NotImplementedError("JSON reporter is implemented in Phase 5.")
88
+
89
+
90
+ def render_html(stats: list[EndpointStats], config: LoadTestConfig) -> str:
91
+ raise NotImplementedError("HTML reporter is implemented in Phase 5.")
@@ -0,0 +1,77 @@
1
+ """Registers `jac loadtest` with jaclang's CommandRegistry at module import time.
2
+
3
+ The @registry.command decorator fires when this module is imported (which happens
4
+ when jaclang loads this entry-point). The JacLoadtestCmd class is a marker that
5
+ the entry-point points to — importing it is what matters, not instantiating it.
6
+ """
7
+ from jaclang.cli.registry import get_registry
8
+ from jaclang.cli.command import Arg, ArgKind
9
+
10
+ registry = get_registry()
11
+
12
+
13
+ @registry.command(
14
+ name="loadtest",
15
+ help="HAR-based load testing for jac-scale apps",
16
+ args=[
17
+ Arg.create("har_file", kind=ArgKind.POSITIONAL, help="Path to .har file"),
18
+ Arg.create("url", typ=str, default=None, short="",
19
+ help="Target base URL (e.g. http://localhost:8000)"),
20
+ Arg.create("mode", typ=str, default="monolith", short="",
21
+ help="Deployment mode: monolith or microservice"),
22
+ Arg.create("vus", typ=int, default=1, short="",
23
+ help="Number of virtual users"),
24
+ Arg.create("duration", typ=str, default="30s", short="",
25
+ help="Test duration (e.g. 30s, 2m, 1h)"),
26
+ Arg.create("iterations", typ=int, default=None, short="",
27
+ help="Iteration cap per VU (alternative to --duration)"),
28
+ Arg.create("ramp-up", typ=str, default="0s", short="",
29
+ help="Time to ramp up to full VU count"),
30
+ Arg.create("timeout", typ=str, default="30s", short="",
31
+ help="Per-request timeout"),
32
+ Arg.create("think-time", typ=str, default="none", short="",
33
+ help="Inter-request delay: none, real, or scaled"),
34
+ Arg.create("think-time-scale", typ=float, default=1.0, short="",
35
+ help="Multiplier when --think-time scaled"),
36
+ Arg.create("username", typ=str, default=None, short="",
37
+ help="Username for shared-credential auth"),
38
+ Arg.create("password", typ=str, default=None, short="",
39
+ help="Password for shared-credential auth"),
40
+ Arg.create("credentials-file", typ=str, default=None, short="",
41
+ help="CSV file with username,password rows (one per VU)"),
42
+ Arg.create("login-path", typ=str, default="/user/login", short="",
43
+ help="URL path detected as the login entry"),
44
+ Arg.create("include-static", typ=bool, default=False, short="",
45
+ help="Do not skip image/font/CSS entries"),
46
+ Arg.create("rps", typ=int, default=0, short="",
47
+ help="Global requests-per-second cap (0 = unlimited)"),
48
+ Arg.create("max-samples", typ=int, default=1_000_000, short="",
49
+ help="Max raw request records kept in memory for percentile calc"),
50
+ Arg.create("services-map", typ=str, default=None, short="",
51
+ help='JSON map of service name to URL e.g. \'{"svc":"http://host:port"}\''),
52
+ Arg.create("csrf", typ=bool, default=False, short="",
53
+ help="Enable CSRF token detection and injection"),
54
+ Arg.create("fail-on-error-rate", typ=float, default=None, short="",
55
+ help="Exit 1 if error rate exceeds N percent"),
56
+ Arg.create("fail-on-p95", typ=float, default=None, short="",
57
+ help="Exit 1 if p95 latency exceeds N milliseconds"),
58
+ Arg.create("fail-on-p99", typ=float, default=None, short="",
59
+ help="Exit 1 if p99 latency exceeds N milliseconds"),
60
+ Arg.create("abort-on-fail", typ=bool, default=False, short="",
61
+ help="Stop test immediately when any threshold is breached"),
62
+ Arg.create("threshold-start-delay", typ=str, default="0s", short="",
63
+ help="Delay threshold evaluation N seconds from test start"),
64
+ Arg.create("report-format", typ=str, default="console", short="",
65
+ help="Output format: console, json, or html"),
66
+ Arg.create("report-out", typ=str, default=None, short="",
67
+ help="Output file path for json/html reports"),
68
+ Arg.create("debug", typ=bool, default=False, short="",
69
+ help="Print each request and response status to stderr during run"),
70
+ ],
71
+ group="testing",
72
+ source="jac-loadtest",
73
+ )
74
+ def loadtest(**kwargs) -> None:
75
+ import types
76
+ from jac_loadtest.cli import run
77
+ run(types.SimpleNamespace(**kwargs))
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: jac-loadtest
3
+ Version: 0.1.0
4
+ Summary: HAR-based load testing CLI for jac-scale applications
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: jaclang==0.15.2
8
+ Requires-Dist: jac-scale==0.2.16
9
+ Requires-Dist: rich>=13.0.0
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0; extra == "test"
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
13
+ Requires-Dist: pytest-mock>=3.12; extra == "test"
14
+ Requires-Dist: aiohttp[speedups]>=3.9; extra == "test"
15
+
16
+ # jac-loadtest
17
+
18
+ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci/tree/main/jac-scale) applications. Capture real browser traffic via Chrome DevTools, export it as a `.har` file, and replay it under load — no scripting required.
19
+
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
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Minimal: 1 VU, 30s
26
+ jac loadtest recording.har --url http://localhost:8000
27
+
28
+ # 50 VUs with 10s ramp-up
29
+ jac loadtest recording.har --url http://localhost:8000 --vus 50 --ramp-up 10s --duration 60s
30
+
31
+ # Authenticated test with per-VU credentials
32
+ jac loadtest recording.har --url http://localhost:8000 --vus 20 --credentials-file creds.csv
33
+
34
+ # CI-friendly with thresholds
35
+ jac loadtest recording.har --url http://localhost:8000 \
36
+ --vus 10 --duration 30s --fail-on-p95 500 --fail-on-error-rate 1
37
+ ```
38
+
39
+ ## Developer Setup
40
+
41
+ ```bash
42
+ # 1. Create and activate a conda env (Python 3.12 required)
43
+ conda create -n load python=3.12
44
+ conda activate load
45
+
46
+ # 2. Install the package in editable mode (runtime deps only)
47
+ pip install -e .
48
+
49
+ # 3. Also install test dependencies (when running tests)
50
+ pip install -e ".[test]"
51
+
52
+ # 4. Verify the command is registered
53
+ jac loadtest --help
54
+ ```
55
+
56
+ ## Project Layout
57
+
58
+ ```
59
+ jac_loadtest/
60
+ ├── plugin.py — registers `jac loadtest` via jaclang entry-points
61
+ ├── cli.py — argument wiring; JacMetaImporter bootstrap
62
+ ├── config.py — LoadTestConfig dataclass (three-layer resolution)
63
+ ├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
64
+ ├── bridge/ — jac-scale-aware adapters (auth, topology)
65
+ └── output/ — console, JSON, HTML reporters
66
+ ```
67
+
68
+ The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
69
+
70
+ ## HAR Compatibility
71
+
72
+ Tested with HAR **1.1** and **1.2** (the format exported by Chrome DevTools, Firefox, Postman, and Insomnia). Files from other versions are parsed with a warning — open an issue if something breaks.
73
+
74
+ ## Documentation
75
+
76
+ - [Architecture](docs/ARCHITECTURE.md) — module map, data flow, design decisions
77
+ - [Roadmap](docs/ROADMAP.md) — delivery phases and exit criteria
78
+ - [Verification](docs/VERIFICATION.md) — phase-by-phase manual and automated verification checklists
@@ -0,0 +1,21 @@
1
+ README.md
2
+ pyproject.toml
3
+ jac_loadtest/__init__.py
4
+ jac_loadtest/cli.py
5
+ jac_loadtest/config.py
6
+ jac_loadtest/plugin.py
7
+ jac_loadtest.egg-info/PKG-INFO
8
+ jac_loadtest.egg-info/SOURCES.txt
9
+ jac_loadtest.egg-info/dependency_links.txt
10
+ jac_loadtest.egg-info/entry_points.txt
11
+ jac_loadtest.egg-info/requires.txt
12
+ jac_loadtest.egg-info/top_level.txt
13
+ jac_loadtest/bridge/__init__.py
14
+ jac_loadtest/bridge/auth.py
15
+ jac_loadtest/bridge/topology.py
16
+ jac_loadtest/core/__init__.py
17
+ jac_loadtest/core/engine.py
18
+ jac_loadtest/core/har_parser.py
19
+ jac_loadtest/core/metrics.py
20
+ jac_loadtest/output/__init__.py
21
+ jac_loadtest/output/reporter.py
@@ -0,0 +1,2 @@
1
+ [jac]
2
+ loadtest = jac_loadtest.plugin:loadtest
@@ -0,0 +1,9 @@
1
+ jaclang==0.15.2
2
+ jac-scale==0.2.16
3
+ rich>=13.0.0
4
+
5
+ [test]
6
+ pytest>=8.0
7
+ pytest-asyncio>=0.23
8
+ pytest-mock>=3.12
9
+ aiohttp[speedups]>=3.9
@@ -0,0 +1 @@
1
+ jac_loadtest
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "jac-loadtest"
3
+ version = "0.1.0"
4
+ description = "HAR-based load testing CLI for jac-scale applications"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "jaclang==0.15.2",
9
+ "jac-scale==0.2.16",
10
+ "rich>=13.0.0",
11
+ ]
12
+
13
+ [project.entry-points."jac"]
14
+ loadtest = "jac_loadtest.plugin:loadtest"
15
+
16
+ [project.optional-dependencies]
17
+ test = [
18
+ "pytest>=8.0",
19
+ "pytest-asyncio>=0.23",
20
+ "pytest-mock>=3.12",
21
+ "aiohttp[speedups]>=3.9",
22
+ ]
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["jac_loadtest*"]
26
+
27
+ [tool.pytest.ini_options]
28
+ asyncio_mode = "auto"
29
+ testpaths = ["tests"]
30
+ markers = [
31
+ "unit: pure logic, no network",
32
+ "integration: in-process aiohttp test server",
33
+ "e2e: full pipeline smoke test",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["setuptools>=68.0"]
38
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+