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.
- jac_loadtest-0.1.0/PKG-INFO +78 -0
- jac_loadtest-0.1.0/README.md +63 -0
- jac_loadtest-0.1.0/jac_loadtest/__init__.py +0 -0
- jac_loadtest-0.1.0/jac_loadtest/bridge/__init__.py +0 -0
- jac_loadtest-0.1.0/jac_loadtest/bridge/auth.py +15 -0
- jac_loadtest-0.1.0/jac_loadtest/bridge/topology.py +24 -0
- jac_loadtest-0.1.0/jac_loadtest/cli.py +56 -0
- jac_loadtest-0.1.0/jac_loadtest/config.py +123 -0
- jac_loadtest-0.1.0/jac_loadtest/core/__init__.py +0 -0
- jac_loadtest-0.1.0/jac_loadtest/core/engine.py +162 -0
- jac_loadtest-0.1.0/jac_loadtest/core/har_parser.py +161 -0
- jac_loadtest-0.1.0/jac_loadtest/core/metrics.py +161 -0
- jac_loadtest-0.1.0/jac_loadtest/output/__init__.py +0 -0
- jac_loadtest-0.1.0/jac_loadtest/output/reporter.py +91 -0
- jac_loadtest-0.1.0/jac_loadtest/plugin.py +77 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/PKG-INFO +78 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/SOURCES.txt +21 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/dependency_links.txt +1 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/entry_points.txt +2 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/requires.txt +9 -0
- jac_loadtest-0.1.0/jac_loadtest.egg-info/top_level.txt +1 -0
- jac_loadtest-0.1.0/pyproject.toml +38 -0
- jac_loadtest-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"
|