jac-loadtest 0.2.2__tar.gz → 0.3.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.2.2 → jac_loadtest-0.3.0}/PKG-INFO +24 -14
- {jac_loadtest-0.2.2 → jac_loadtest-0.3.0}/README.md +6 -8
- jac_loadtest-0.3.0/_jac_build.py +19 -0
- jac_loadtest-0.3.0/jac.toml +41 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/bridge/auth.jac +94 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/bridge/topology.jac +165 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/cli.jac +152 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/config.jac +195 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/core/engine.jac +352 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/core/har_parser.jac +294 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/core/metrics.jac +220 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/core/process_runner.jac +207 -0
- jac_loadtest-0.3.0/jac_loadtest_cli/output/reporter.jac +359 -0
- jac_loadtest-0.2.2/jac_loadtest/plugin.py → jac_loadtest-0.3.0/jac_loadtest_cli/plugin.jac +12 -16
- jac_loadtest-0.3.0/jac_loadtest_cli/templates/reporter_template.html +271 -0
- jac_loadtest-0.3.0/pyproject.toml +35 -0
- jac_loadtest-0.2.2/jac_loadtest/bridge/auth.py +0 -114
- jac_loadtest-0.2.2/jac_loadtest/bridge/topology.py +0 -201
- jac_loadtest-0.2.2/jac_loadtest/cli.py +0 -86
- jac_loadtest-0.2.2/jac_loadtest/config.py +0 -149
- jac_loadtest-0.2.2/jac_loadtest/core/engine.py +0 -228
- jac_loadtest-0.2.2/jac_loadtest/core/har_parser.py +0 -279
- jac_loadtest-0.2.2/jac_loadtest/core/metrics.py +0 -170
- jac_loadtest-0.2.2/jac_loadtest/output/reporter.py +0 -115
- jac_loadtest-0.2.2/jac_loadtest.egg-info/PKG-INFO +0 -97
- jac_loadtest-0.2.2/jac_loadtest.egg-info/SOURCES.txt +0 -21
- jac_loadtest-0.2.2/jac_loadtest.egg-info/dependency_links.txt +0 -1
- jac_loadtest-0.2.2/jac_loadtest.egg-info/entry_points.txt +0 -2
- jac_loadtest-0.2.2/jac_loadtest.egg-info/requires.txt +0 -9
- jac_loadtest-0.2.2/jac_loadtest.egg-info/top_level.txt +0 -1
- jac_loadtest-0.2.2/pyproject.toml +0 -38
- jac_loadtest-0.2.2/setup.cfg +0 -4
- /jac_loadtest-0.2.2/jac_loadtest/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/__init__.jac +0 -0
- /jac_loadtest-0.2.2/jac_loadtest/bridge/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/bridge/__init__.jac +0 -0
- /jac_loadtest-0.2.2/jac_loadtest/core/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/core/__init__.jac +0 -0
- /jac_loadtest-0.2.2/jac_loadtest/output/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/output/__init__.jac +0 -0
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jac-loadtest
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: HAR-based load testing
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: HAR-based load testing library for jac-scale applications
|
|
5
|
+
Author-email: Sahan Udayanga <sahanudayangaof@gmail.com>, Ravimal Ranathunga <ravimalranathunga@gmail.com>
|
|
6
|
+
Maintainer-email: Sahan Udayanga <sahanudayangaof@gmail.com>, Ravimal Ranathunga <ravimalranathunga@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: load-testing,har,jac,jaclang,jaseci,jac-scale,performance
|
|
5
9
|
Requires-Python: >=3.12
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
Project-URL: Repository, https://github.com/SahanUday/jac-loadtest
|
|
11
|
+
Project-URL: Source, https://github.com/SahanUday/jac-loadtest/main/jac-loadtest-cli
|
|
12
|
+
Project-URL: Issues, https://github.com/SahanUday/jac-loadtest/issues
|
|
13
|
+
Requires-Dist: jac-scale>=0.2.16
|
|
14
|
+
Requires-Dist: aiohttp>=3.9.0,<4.0.0
|
|
9
15
|
Requires-Dist: rich>=13.0.0
|
|
16
|
+
Requires-Dist: requests>=2.28.0
|
|
10
17
|
Provides-Extra: test
|
|
11
18
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
12
19
|
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
13
20
|
Requires-Dist: pytest-mock>=3.12; extra == "test"
|
|
14
|
-
Requires-Dist: aiohttp[speedups]>=3.9; extra == "test"
|
|
21
|
+
Requires-Dist: aiohttp[speedups]>=3.9.0,<4.0.0; extra == "test"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
15
27
|
|
|
16
28
|
# jac-loadtest
|
|
17
29
|
|
|
@@ -63,10 +75,10 @@ conda create -n load python=3.12
|
|
|
63
75
|
conda activate load
|
|
64
76
|
|
|
65
77
|
# 2. Install the package in editable mode (runtime deps only)
|
|
66
|
-
|
|
78
|
+
jac install -e .
|
|
67
79
|
|
|
68
80
|
# 3. Also install test dependencies (when running tests)
|
|
69
|
-
|
|
81
|
+
jac install -x test
|
|
70
82
|
|
|
71
83
|
# 4. Verify the command is registered
|
|
72
84
|
jac loadtest --help
|
|
@@ -75,17 +87,15 @@ jac loadtest --help
|
|
|
75
87
|
## Project Layout
|
|
76
88
|
|
|
77
89
|
```
|
|
78
|
-
|
|
79
|
-
├── plugin.
|
|
80
|
-
├── cli.
|
|
81
|
-
├── config.
|
|
90
|
+
jac_loadtest_cli/
|
|
91
|
+
├── plugin.jac — registers `jac loadtest` via jaclang entry-points
|
|
92
|
+
├── cli.jac — argument wiring and run orchestration
|
|
93
|
+
├── config.jac — LoadTestConfig dataclass (three-layer resolution)
|
|
82
94
|
├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
|
|
83
95
|
├── bridge/ — jac-scale-aware adapters (auth, topology)
|
|
84
96
|
└── output/ — console, JSON, HTML reporters
|
|
85
97
|
```
|
|
86
98
|
|
|
87
|
-
The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
|
|
88
|
-
|
|
89
99
|
## HAR Compatibility
|
|
90
100
|
|
|
91
101
|
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.
|
|
@@ -48,10 +48,10 @@ conda create -n load python=3.12
|
|
|
48
48
|
conda activate load
|
|
49
49
|
|
|
50
50
|
# 2. Install the package in editable mode (runtime deps only)
|
|
51
|
-
|
|
51
|
+
jac install -e .
|
|
52
52
|
|
|
53
53
|
# 3. Also install test dependencies (when running tests)
|
|
54
|
-
|
|
54
|
+
jac install -x test
|
|
55
55
|
|
|
56
56
|
# 4. Verify the command is registered
|
|
57
57
|
jac loadtest --help
|
|
@@ -60,17 +60,15 @@ jac loadtest --help
|
|
|
60
60
|
## Project Layout
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
|
|
64
|
-
├── plugin.
|
|
65
|
-
├── cli.
|
|
66
|
-
├── config.
|
|
63
|
+
jac_loadtest_cli/
|
|
64
|
+
├── plugin.jac — registers `jac loadtest` via jaclang entry-points
|
|
65
|
+
├── cli.jac — argument wiring and run orchestration
|
|
66
|
+
├── config.jac — LoadTestConfig dataclass (three-layer resolution)
|
|
67
67
|
├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
|
|
68
68
|
├── bridge/ — jac-scale-aware adapters (auth, topology)
|
|
69
69
|
└── output/ — console, JSON, HTML reporters
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
|
|
73
|
-
|
|
74
72
|
## HAR Compatibility
|
|
75
73
|
|
|
76
74
|
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.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Bundled PEP 517 build backend — generated by jac bundle, do not edit."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_requires_for_build_wheel(config_settings=None):
|
|
5
|
+
return []
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from jaclang.project.config import get_config
|
|
11
|
+
from jaclang.publish.builder import WheelBuilder
|
|
12
|
+
|
|
13
|
+
proj_root = Path.cwd()
|
|
14
|
+
config = get_config(start_path=proj_root, force_discover=True)
|
|
15
|
+
if config is None:
|
|
16
|
+
raise RuntimeError(f"No jac.toml found in {proj_root}.")
|
|
17
|
+
return WheelBuilder(config=config, project_root=proj_root).build_to(
|
|
18
|
+
Path(wheel_directory)
|
|
19
|
+
).name
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jac-loadtest"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "HAR-based load testing library for jac-scale applications"
|
|
5
|
+
authors = [{ name = "Sahan Udayanga", email = "sahanudayangaof@gmail.com" }, { name = "Ravimal Ranathunga", email = "ravimalranathunga@gmail.com" }]
|
|
6
|
+
maintainers = [{ name = "Sahan Udayanga", email = "sahanudayangaof@gmail.com" }, { name = "Ravimal Ranathunga", email = "ravimalranathunga@gmail.com" }]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
keywords = ["load-testing", "har", "jac", "jaclang", "jaseci", "jac-scale", "performance"]
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
repository = "https://github.com/SahanUday/jac-loadtest"
|
|
14
|
+
source = "https://github.com/SahanUday/jac-loadtest/main/jac-loadtest-cli"
|
|
15
|
+
issues = "https://github.com/SahanUday/jac-loadtest/issues"
|
|
16
|
+
|
|
17
|
+
[project.include]
|
|
18
|
+
packages = ["jac_loadtest_cli"]
|
|
19
|
+
|
|
20
|
+
[project.include.data]
|
|
21
|
+
jac_loadtest_cli = ["templates/*.html"]
|
|
22
|
+
|
|
23
|
+
[dependencies]
|
|
24
|
+
jac-scale = ">=0.2.16"
|
|
25
|
+
aiohttp = ">=3.9.0,<4.0.0"
|
|
26
|
+
rich = ">=13.0.0"
|
|
27
|
+
requests = ">=2.28.0"
|
|
28
|
+
|
|
29
|
+
[dev-dependencies]
|
|
30
|
+
pytest = ">=8.0"
|
|
31
|
+
pytest-asyncio = ">=0.23"
|
|
32
|
+
pytest-mock = ">=3.12"
|
|
33
|
+
|
|
34
|
+
[optional-dependencies.test]
|
|
35
|
+
pytest = ">=8.0"
|
|
36
|
+
pytest-asyncio = ">=0.23"
|
|
37
|
+
pytest-mock = ">=3.12"
|
|
38
|
+
"aiohttp[speedups]" = ">=3.9.0,<4.0.0"
|
|
39
|
+
|
|
40
|
+
[entrypoints.jac]
|
|
41
|
+
loadtest = "jac_loadtest_cli.plugin:loadtest"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import csv;
|
|
2
|
+
import aiohttp;
|
|
3
|
+
import from ..config { LoadTestConfig }
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(Exception) { }
|
|
6
|
+
|
|
7
|
+
obj Credential {
|
|
8
|
+
has username: str;
|
|
9
|
+
has password: str;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
obj AuthProvider {
|
|
13
|
+
has _credentials: list[Credential];
|
|
14
|
+
has _login_path: str = "/user/login";
|
|
15
|
+
|
|
16
|
+
class def from_config(config: LoadTestConfig) -> AuthProvider | None {
|
|
17
|
+
if config.credentials_file {
|
|
18
|
+
creds = _load_csv(config.credentials_file);
|
|
19
|
+
} elif config.username and config.password {
|
|
20
|
+
creds = [Credential(username=config.username, password=config.password)];
|
|
21
|
+
} else {
|
|
22
|
+
return None;
|
|
23
|
+
}
|
|
24
|
+
return AuthProvider(_credentials=creds, _login_path=config.login_path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def get_credential(vu_id: int) -> Credential {
|
|
28
|
+
return self._credentials[vu_id % len(self._credentials)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async def authenticate(vu_id: int, session: aiohttp.ClientSession, base_url: str) -> str {
|
|
32
|
+
cred = self.get_credential(vu_id);
|
|
33
|
+
identity_type = "email" if "@" in cred.username else "username";
|
|
34
|
+
payload = {
|
|
35
|
+
"identity": {"type": identity_type, "value": cred.username},
|
|
36
|
+
"credential": {"type": "password", "password": cred.password},
|
|
37
|
+
};
|
|
38
|
+
url = base_url.rstrip("/") + self._login_path;
|
|
39
|
+
try {
|
|
40
|
+
async with session.post(url, json=payload) as resp {
|
|
41
|
+
if resp.status == 401 {
|
|
42
|
+
raise AuthenticationError(
|
|
43
|
+
f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
|
|
44
|
+
"401 Unauthorized — check credentials."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if not resp.ok {
|
|
48
|
+
raise AuthenticationError(
|
|
49
|
+
f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
|
|
50
|
+
f"server returned {resp.status}."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
body = await resp.json();
|
|
54
|
+
}
|
|
55
|
+
} except aiohttp.ClientConnectorError as exc {
|
|
56
|
+
raise AuthenticationError(
|
|
57
|
+
f"Cannot reach login endpoint {url!r}: {exc}"
|
|
58
|
+
) from exc;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return body["data"]["token"];
|
|
62
|
+
} except (KeyError, TypeError) as exc {
|
|
63
|
+
raise AuthenticationError(
|
|
64
|
+
f"Unexpected login response shape from {url!r}: {body!r}"
|
|
65
|
+
) from exc;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def _load_csv(path: str) -> list[Credential] {
|
|
71
|
+
credentials = [];
|
|
72
|
+
with open(path, newline="") as f {
|
|
73
|
+
reader = csv.reader(f);
|
|
74
|
+
i = 0;
|
|
75
|
+
for row in reader {
|
|
76
|
+
if len(row) < 2 {
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
username = row[0].strip();
|
|
81
|
+
password = row[1].strip();
|
|
82
|
+
if i == 0 and username.lower() == "username" {
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
credentials.append(Credential(username=username, password=password));
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if not credentials {
|
|
91
|
+
raise ValueError(f"No credentials found in {path!r}");
|
|
92
|
+
}
|
|
93
|
+
return credentials;
|
|
94
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import json;
|
|
2
|
+
import os;
|
|
3
|
+
import from urllib.parse { urlparse, urlunparse }
|
|
4
|
+
import from ..config { LoadTestConfig }
|
|
5
|
+
|
|
6
|
+
obj ServiceRoute {
|
|
7
|
+
has name: str;
|
|
8
|
+
has prefix: str;
|
|
9
|
+
has url: str;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
obj TopologyRouter {
|
|
13
|
+
has _routes: list;
|
|
14
|
+
has _fallback_url: str | None = None;
|
|
15
|
+
|
|
16
|
+
def __post_init__() {
|
|
17
|
+
self._routes = sorted(self._routes, key=lambda r: len(r.prefix), reverse=True);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def resolve(entry_url: str) -> tuple {
|
|
21
|
+
parsed = urlparse(entry_url);
|
|
22
|
+
path = parsed.path;
|
|
23
|
+
|
|
24
|
+
for route in self._routes {
|
|
25
|
+
if _prefix_matches(path, route.prefix) {
|
|
26
|
+
t = urlparse(route.url);
|
|
27
|
+
if route.prefix {
|
|
28
|
+
service_path = path[len(route.prefix):] or "/";
|
|
29
|
+
} else {
|
|
30
|
+
service_path = path;
|
|
31
|
+
}
|
|
32
|
+
base_path = t.path.rstrip("/") if t.path else "";
|
|
33
|
+
routed_path = f"{base_path}{service_path}" if base_path else service_path;
|
|
34
|
+
routed = urlunparse((
|
|
35
|
+
t.scheme, t.netloc,
|
|
36
|
+
routed_path, parsed.params, parsed.query, "",
|
|
37
|
+
));
|
|
38
|
+
return (routed, route.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if self._fallback_url {
|
|
43
|
+
t = urlparse(self._fallback_url);
|
|
44
|
+
base_path = t.path.rstrip("/") if t.path else "";
|
|
45
|
+
routed_path = f"{base_path}{path}" if base_path else path;
|
|
46
|
+
routed = urlunparse((
|
|
47
|
+
t.scheme, t.netloc,
|
|
48
|
+
routed_path, parsed.params, parsed.query, "",
|
|
49
|
+
));
|
|
50
|
+
return (routed, "gateway");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"No route for path '{path}' and no --url fallback provided. "
|
|
55
|
+
"Use --services-map or add [plugins.scale.microservices.routes] to jac.toml."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class def from_config(config: LoadTestConfig) -> TopologyRouter {
|
|
60
|
+
if config.mode != "microservice" {
|
|
61
|
+
return TopologyRouter(
|
|
62
|
+
_routes=[ServiceRoute(name="monolith", prefix="", url=config.url or "")],
|
|
63
|
+
_fallback_url=None,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return _build_microservice_topology(config);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def _build_microservice_topology(config: LoadTestConfig) -> TopologyRouter {
|
|
71
|
+
toml_routes = _load_toml_routes();
|
|
72
|
+
|
|
73
|
+
if config.services_map {
|
|
74
|
+
try {
|
|
75
|
+
services_json = json.loads(config.services_map);
|
|
76
|
+
} except json.JSONDecodeError as exc {
|
|
77
|
+
raise ValueError(f"--services-map is not valid JSON: {exc}") from exc;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
routes = [];
|
|
81
|
+
for item in services_json.items() {
|
|
82
|
+
name = item[0];
|
|
83
|
+
url = item[1];
|
|
84
|
+
prefix = toml_routes.get(name);
|
|
85
|
+
if prefix is None {
|
|
86
|
+
if name.startswith("/") {
|
|
87
|
+
prefix = name;
|
|
88
|
+
} else {
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"--services-map key '{name}' has no matching route in jac.toml "
|
|
91
|
+
"and does not start with '/'. "
|
|
92
|
+
"Use a path prefix as the key or add '[plugins.scale.microservices.routes]' to jac.toml."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
routes.append(ServiceRoute(
|
|
97
|
+
name=name.lstrip("/") if name.startswith("/") else name,
|
|
98
|
+
prefix=prefix,
|
|
99
|
+
url=url.rstrip("/"),
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
return TopologyRouter(_routes=routes, _fallback_url=config.url);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if not toml_routes {
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"Microservice mode requires either --services-map or "
|
|
108
|
+
"[plugins.scale.microservices.routes] in jac.toml. Neither was found."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
routes = [];
|
|
113
|
+
missing = [];
|
|
114
|
+
for item in toml_routes.items() {
|
|
115
|
+
name = item[0];
|
|
116
|
+
prefix = item[1];
|
|
117
|
+
env_var = f"JAC_SV_{name.upper()}_URL";
|
|
118
|
+
url = os.environ.get(env_var);
|
|
119
|
+
if not url {
|
|
120
|
+
missing.append(env_var);
|
|
121
|
+
} else {
|
|
122
|
+
routes.append(ServiceRoute(name=name, prefix=prefix, url=url.rstrip("/")));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if missing {
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Microservice mode: missing environment variable(s): {', '.join(missing)}. "
|
|
129
|
+
"Expected format: JAC_SV_<SERVICE_NAME>_URL=http://host:port"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return TopologyRouter(_routes=routes, _fallback_url=config.url);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _prefix_matches(path: str, prefix: str) -> bool {
|
|
137
|
+
if not prefix {
|
|
138
|
+
return True;
|
|
139
|
+
}
|
|
140
|
+
return path == prefix or path.startswith(prefix + "/");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _load_toml_routes -> dict {
|
|
144
|
+
try {
|
|
145
|
+
import from pathlib { Path }
|
|
146
|
+
import from jac_scale.config_loader { get_scale_config, reset_scale_config }
|
|
147
|
+
reset_scale_config();
|
|
148
|
+
ms_config = get_scale_config(project_dir=Path.cwd()).get_microservices_config();
|
|
149
|
+
return dict(ms_config.get("routes", {}));
|
|
150
|
+
} except Exception {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def resolve_url(path: str, routing_table: dict, fallback_url: str | None) -> str {
|
|
156
|
+
for prefix in sorted(routing_table, key=len, reverse=True) {
|
|
157
|
+
if path.startswith(prefix) {
|
|
158
|
+
return routing_table[prefix];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if fallback_url {
|
|
162
|
+
return fallback_url;
|
|
163
|
+
}
|
|
164
|
+
raise ValueError(f"No route for path '{path}' and no --url fallback provided.");
|
|
165
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import sys;
|
|
2
|
+
import asyncio;
|
|
3
|
+
import time;
|
|
4
|
+
import from .config { from_args, LoadTestConfig }
|
|
5
|
+
import from .core.har_parser { parse_har, HarEntry }
|
|
6
|
+
import from .core.engine { run_all_vus }
|
|
7
|
+
import from .core.metrics { MetricsCollector }
|
|
8
|
+
import from .core.process_runner { run_multiprocess }
|
|
9
|
+
import from .output.reporter { render_console, render_json, render_html }
|
|
10
|
+
import from .bridge.auth { AuthProvider, AuthenticationError }
|
|
11
|
+
import from .bridge.topology { TopologyRouter }
|
|
12
|
+
import from rich.console { Console as _Console }
|
|
13
|
+
|
|
14
|
+
def run(args: object) {
|
|
15
|
+
config: LoadTestConfig = from_args(args);
|
|
16
|
+
|
|
17
|
+
if config.mode == "monolith" and not config.url {
|
|
18
|
+
print("Error: --url is required for monolith mode", file=sys.stderr);
|
|
19
|
+
sys.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if not config.har_file {
|
|
23
|
+
print("Error: har_file positional argument is required", file=sys.stderr);
|
|
24
|
+
sys.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
entries: list[HarEntry] = [];
|
|
28
|
+
try {
|
|
29
|
+
entries = parse_har(
|
|
30
|
+
config.har_file,
|
|
31
|
+
target_url=config.url if config.mode == "monolith" else None,
|
|
32
|
+
include_static=config.include_static,
|
|
33
|
+
login_path=config.login_path,
|
|
34
|
+
);
|
|
35
|
+
} except (FileNotFoundError, ValueError) as exc {
|
|
36
|
+
print(f"Error: {exc}", file=sys.stderr);
|
|
37
|
+
sys.exit(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if not entries {
|
|
41
|
+
print(
|
|
42
|
+
"Error: no API entries found in HAR file after filtering. "
|
|
43
|
+
"Use --include-static to include static assets.",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
);
|
|
46
|
+
sys.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
auth_provider: AuthProvider | None = AuthProvider.from_config(config);
|
|
50
|
+
|
|
51
|
+
if config.mode == "microservice" and auth_provider is not None and not config.url {
|
|
52
|
+
print(
|
|
53
|
+
"Error: --url (gateway URL) is required when using authentication "
|
|
54
|
+
"in microservice mode",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
);
|
|
57
|
+
sys.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
topology = TopologyRouter.from_config(config);
|
|
62
|
+
} except ValueError as exc {
|
|
63
|
+
print(f"Error: {exc}", file=sys.stderr);
|
|
64
|
+
sys.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_console = _Console(stderr=True);
|
|
68
|
+
|
|
69
|
+
t_start = time.time();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
with _console.status(f"Running load test — {config.vus} VUs...") {
|
|
73
|
+
if config.workers > 1 {
|
|
74
|
+
metrics = run_multiprocess(entries, config, topology, auth_provider);
|
|
75
|
+
} else {
|
|
76
|
+
metrics = MetricsCollector(max_samples=config.max_samples);
|
|
77
|
+
asyncio.run(
|
|
78
|
+
run_all_vus(
|
|
79
|
+
entries, config, metrics,
|
|
80
|
+
topology=topology,
|
|
81
|
+
auth_provider=auth_provider,
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} except AuthenticationError as exc {
|
|
87
|
+
print(f"Error: {exc}", file=sys.stderr);
|
|
88
|
+
sys.exit(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
duration_s = time.time() - t_start;
|
|
92
|
+
stats = metrics.compute_endpoint_stats(t_start=t_start);
|
|
93
|
+
snapshots = metrics.generate_timeseries(t_start);
|
|
94
|
+
_comp = metrics.completion_percentiles(t_start);
|
|
95
|
+
completion_p50 = _comp[0];
|
|
96
|
+
completion_p95 = _comp[1];
|
|
97
|
+
completion_p99 = _comp[2];
|
|
98
|
+
|
|
99
|
+
fmt = config.report_format;
|
|
100
|
+
|
|
101
|
+
if fmt == "json" {
|
|
102
|
+
output = render_json(
|
|
103
|
+
stats, config,
|
|
104
|
+
actual_duration_s=duration_s,
|
|
105
|
+
total_rps=metrics.global_rps(duration_s),
|
|
106
|
+
snapshots=snapshots,
|
|
107
|
+
completion_p50_s=completion_p50,
|
|
108
|
+
completion_p95_s=completion_p95,
|
|
109
|
+
completion_p99_s=completion_p99,
|
|
110
|
+
);
|
|
111
|
+
if config.report_out {
|
|
112
|
+
with open(config.report_out, "w", encoding="utf-8") as fh {
|
|
113
|
+
fh.write(output);
|
|
114
|
+
}
|
|
115
|
+
print(f"JSON report written to {config.report_out}");
|
|
116
|
+
} else {
|
|
117
|
+
print(output);
|
|
118
|
+
}
|
|
119
|
+
} elif fmt == "html" {
|
|
120
|
+
if not config.report_out {
|
|
121
|
+
print(
|
|
122
|
+
"Error: --report-out <path> is required for --report-format html",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
);
|
|
125
|
+
sys.exit(2);
|
|
126
|
+
}
|
|
127
|
+
output = render_html(
|
|
128
|
+
stats, config,
|
|
129
|
+
actual_duration_s=duration_s,
|
|
130
|
+
total_rps=metrics.global_rps(duration_s),
|
|
131
|
+
snapshots=snapshots,
|
|
132
|
+
completion_p50_s=completion_p50,
|
|
133
|
+
completion_p95_s=completion_p95,
|
|
134
|
+
completion_p99_s=completion_p99,
|
|
135
|
+
);
|
|
136
|
+
with open(config.report_out, "w", encoding="utf-8") as fh {
|
|
137
|
+
fh.write(output);
|
|
138
|
+
}
|
|
139
|
+
print(f"HTML report written to {config.report_out}", file=sys.stderr);
|
|
140
|
+
} else {
|
|
141
|
+
render_console(
|
|
142
|
+
stats, config,
|
|
143
|
+
actual_duration_s=duration_s,
|
|
144
|
+
total_rps=metrics.global_rps(duration_s),
|
|
145
|
+
completion_p50_s=completion_p50,
|
|
146
|
+
completion_p95_s=completion_p95,
|
|
147
|
+
completion_p99_s=completion_p99,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|