strobengine 0.5.1__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {strobengine-0.5.1 → strobengine-0.5.2}/CHANGELOG.md +22 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/Cargo.lock +1 -1
- {strobengine-0.5.1 → strobengine-0.5.2}/Cargo.toml +1 -1
- {strobengine-0.5.1 → strobengine-0.5.2}/PKG-INFO +1 -1
- {strobengine-0.5.1 → strobengine-0.5.2}/pyproject.toml +1 -1
- {strobengine-0.5.1 → strobengine-0.5.2}/src/lib.rs +15 -13
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/websocket.rs +3 -2
- {strobengine-0.5.1 → strobengine-0.5.2}/src/report/schema.rs +1 -1
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporter.py +7 -1
- strobengine-0.5.2/src/strobengine/reporting/__init__.py +8 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/baseline.py +24 -2
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/html_report.py +6 -4
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/junit_report.py +6 -4
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/markdown_report.py +5 -4
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_reporting.py +18 -91
- {strobengine-0.5.1 → strobengine-0.5.2}/uv.lock +1 -1
- strobengine-0.5.1/tests/e2e/__init__.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.dockerignore +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/ci.yml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/docker-release.yml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/publish-test.yml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/publish.yml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.gitignore +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.pre-commit-config.yaml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/.python-version +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/Dockerfile +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/LICENSE +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/Makefile +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/README.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/cliff.toml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/benchmarks.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/cli.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/dependencies.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/docker.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/grpc.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/http3.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/http_methods.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/infrastructure.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/install.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/quickstart.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/release.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/reports.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/roadmap.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/sse.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/table_of_contents.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/testing.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/docs/websockets.md +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/grpc.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/http3.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/http_methods.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/load_test.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/spike_test.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/sse.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/stress_test.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/examples/websocket.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/rust-toolchain.toml +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/scripts/release.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/chaos.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/config.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/logging.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/metrics.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/progress.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc_parser.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc_reflection.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/http.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/http3.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/mod.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/sse.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/report/mod.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/report/writer.rs +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/__init__.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/_strobengine.pyi +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/cli.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/constants.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/engine.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/py.typed +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/assets/chart.min.js +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/csv_report.py +0 -0
- {strobengine-0.5.1/src/strobengine/reporting → strobengine-0.5.2/tests}/__init__.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/conftest.py +0 -0
- {strobengine-0.5.1/tests → strobengine-0.5.2/tests/e2e}/__init__.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/conftest.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/mock_server.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_cli_interface.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_grpc_e2e.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_http3_e2e.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_http_scenarios.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_performance_sanity.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_persistence_e2e.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_sse_e2e.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_websocket_e2e.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/fixtures/service.proto +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_cli.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_engine.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_logging.py +0 -0
- {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_reporter.py +0 -0
|
@@ -4,6 +4,24 @@ All notable changes to `strobengine` will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.2] - 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- *(report)* Remove forced 2-decimal rounding of RPS in report schema
|
|
12
|
+
- *(lib)* Log report persistence errors instead of silently discarding
|
|
13
|
+
- *(reporter)* Replace hardcoded version string with dynamic resolution
|
|
14
|
+
- *(reporting)* Validate baseline artifact structure in compute_comparison
|
|
15
|
+
|
|
16
|
+
### Refactoring
|
|
17
|
+
|
|
18
|
+
- *(lib)* Remove unnecessary clones on config.body, config.form, config.headers
|
|
19
|
+
- *(websocket)* Remove unnecessary payload_bytes.clone() in publisher iteration
|
|
20
|
+
- *(reporting)* Extract us_to_ms helper for latency conversion
|
|
21
|
+
|
|
22
|
+
### Styling
|
|
23
|
+
|
|
24
|
+
- *(tests)* Move duplicate inline imports to top of test_reporting.py
|
|
7
25
|
## [0.5.1] - 2026-09-01
|
|
8
26
|
|
|
9
27
|
### Bug Fixes
|
|
@@ -14,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
32
|
- *(reporting)* Move rich import inside print_cli_comparison
|
|
15
33
|
- *(websocket)* Warn on invalid WebSocket headers instead of silent drop
|
|
16
34
|
|
|
35
|
+
### Miscellaneous Tasks
|
|
36
|
+
|
|
37
|
+
- *(release)* Bump version to 0.5.1
|
|
38
|
+
|
|
17
39
|
### Refactoring
|
|
18
40
|
|
|
19
41
|
- *(reporting)* Remove dead render_markdown_report function
|
|
@@ -72,11 +72,11 @@ fn parse_method(method_str: &str) -> PyResult<Method> {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
fn parse_body(body: Option
|
|
76
|
-
body.map(bytes::Bytes::
|
|
75
|
+
fn parse_body(body: Option<&str>) -> Option<bytes::Bytes> {
|
|
76
|
+
body.map(|b| bytes::Bytes::copy_from_slice(b.as_bytes()))
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
fn parse_form(form: Option
|
|
79
|
+
fn parse_form(form: Option<&[(String, String)]>) -> Option<bytes::Bytes> {
|
|
80
80
|
form.map(|pairs| {
|
|
81
81
|
let encoded: String = pairs
|
|
82
82
|
.iter()
|
|
@@ -87,7 +87,7 @@ fn parse_form(form: Option<Vec<(String, String)>>) -> Option<bytes::Bytes> {
|
|
|
87
87
|
})
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
fn parse_headers(headers: Option
|
|
90
|
+
fn parse_headers(headers: Option<&[(String, String)]>) -> PyResult<HeaderMap> {
|
|
91
91
|
let mut header_map = HeaderMap::new();
|
|
92
92
|
|
|
93
93
|
if let Some(h) = headers {
|
|
@@ -96,7 +96,7 @@ fn parse_headers(headers: Option<Vec<(String, String)>>) -> PyResult<HeaderMap>
|
|
|
96
96
|
pyo3::exceptions::PyValueError::new_err(format!("Invalid header name '{k}': {e}"))
|
|
97
97
|
})?;
|
|
98
98
|
|
|
99
|
-
let val = HeaderValue::from_str(
|
|
99
|
+
let val = HeaderValue::from_str(v).map_err(|e| {
|
|
100
100
|
pyo3::exceptions::PyValueError::new_err(format!(
|
|
101
101
|
"Invalid header value for '{k}': {e}"
|
|
102
102
|
))
|
|
@@ -479,9 +479,9 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
479
479
|
protocols::detect_protocol(&url, &config, chaos)
|
|
480
480
|
} else {
|
|
481
481
|
let method = parse_method(&config.method)?;
|
|
482
|
-
let body = parse_body(config.body.
|
|
483
|
-
let form = parse_form(config.form.
|
|
484
|
-
let header_map = parse_headers(config.headers.
|
|
482
|
+
let body = parse_body(config.body.as_deref());
|
|
483
|
+
let form = parse_form(config.form.as_deref());
|
|
484
|
+
let header_map = parse_headers(config.headers.as_deref())?;
|
|
485
485
|
|
|
486
486
|
// Resolve payload and auto-inject Content-Type
|
|
487
487
|
let is_form = form.is_some();
|
|
@@ -533,11 +533,13 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
|
|
|
533
533
|
if !config.no_save {
|
|
534
534
|
let artifact =
|
|
535
535
|
report::schema::ReportArtifact::from_summary_and_config(&summary, &config);
|
|
536
|
-
let
|
|
536
|
+
if let Err(e) = report::writer::save_report_json(
|
|
537
537
|
&artifact,
|
|
538
538
|
config.output_dir.as_deref().map(std::path::Path::new),
|
|
539
539
|
false,
|
|
540
|
-
)
|
|
540
|
+
) {
|
|
541
|
+
tracing::warn!(error = %e, "failed to save report artifact");
|
|
542
|
+
}
|
|
541
543
|
}
|
|
542
544
|
|
|
543
545
|
Ok(summary)
|
|
@@ -575,9 +577,9 @@ fn run_load_profiles(
|
|
|
575
577
|
let chaos_engine = ChaosEngine::new(chaos, chaos_rate);
|
|
576
578
|
|
|
577
579
|
let method = parse_method(method)?;
|
|
578
|
-
let raw_body = parse_body(body);
|
|
579
|
-
let raw_form = parse_form(form);
|
|
580
|
-
let mut header_map = parse_headers(headers.
|
|
580
|
+
let raw_body = parse_body(body.as_deref());
|
|
581
|
+
let raw_form = parse_form(form.as_deref());
|
|
582
|
+
let mut header_map = parse_headers(headers.as_deref())?;
|
|
581
583
|
|
|
582
584
|
// Resolve payload and auto-inject Content-Type
|
|
583
585
|
let is_form = raw_form.is_some();
|
|
@@ -355,8 +355,9 @@ impl WebSocketEngine {
|
|
|
355
355
|
Some(w) => w,
|
|
356
356
|
None => return RequestMetric::error(req_start.elapsed().as_micros()),
|
|
357
357
|
};
|
|
358
|
+
let payload_len = payload_bytes.len() as u64;
|
|
358
359
|
let send_result = write
|
|
359
|
-
.send(Message::Binary(Bytes::from(payload_bytes
|
|
360
|
+
.send(Message::Binary(Bytes::from(payload_bytes)))
|
|
360
361
|
.await;
|
|
361
362
|
|
|
362
363
|
let latency_micros = req_start.elapsed().as_micros();
|
|
@@ -367,7 +368,7 @@ impl WebSocketEngine {
|
|
|
367
368
|
RequestMetric {
|
|
368
369
|
latency_micros,
|
|
369
370
|
status_code: 200,
|
|
370
|
-
bytes_received:
|
|
371
|
+
bytes_received: payload_len,
|
|
371
372
|
is_reconnect: false,
|
|
372
373
|
connection_latency_us: None,
|
|
373
374
|
timestamp_sent_ns: Some(wallclock_ns()),
|
|
@@ -115,7 +115,7 @@ impl ReportArtifact {
|
|
|
115
115
|
total_requests: summary.total_requests as u64,
|
|
116
116
|
successful_requests: successful,
|
|
117
117
|
failed_requests: summary.total_errors as u64,
|
|
118
|
-
rps
|
|
118
|
+
rps,
|
|
119
119
|
bytes_transferred: summary.total_bytes_received,
|
|
120
120
|
},
|
|
121
121
|
latency_percentiles: LatencyPercentiles {
|
|
@@ -230,11 +230,17 @@ def _slugify_url(url: str) -> str:
|
|
|
230
230
|
def _get_system_info() -> dict:
|
|
231
231
|
"""Collect basic system information for report metadata."""
|
|
232
232
|
import socket
|
|
233
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
pkg_version = version("strobengine")
|
|
237
|
+
except PackageNotFoundError:
|
|
238
|
+
pkg_version = "0.0.0-dev"
|
|
233
239
|
|
|
234
240
|
return {
|
|
235
241
|
"hostname": socket.gethostname(),
|
|
236
242
|
"platform": sys.platform,
|
|
237
|
-
"version":
|
|
243
|
+
"version": pkg_version,
|
|
238
244
|
}
|
|
239
245
|
|
|
240
246
|
|
|
@@ -6,6 +6,8 @@ import json
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from strobengine.reporting import us_to_ms
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
def load_baseline_artifact(
|
|
11
13
|
report_dir: Path = Path(".strobengine/reports"),
|
|
@@ -44,6 +46,8 @@ def compute_comparison(current: dict, baseline: dict) -> dict:
|
|
|
44
46
|
|
|
45
47
|
Returns a dict with baseline metadata, current values, and percentage/point
|
|
46
48
|
deltas for RPS, P95 latency, and error rate.
|
|
49
|
+
|
|
50
|
+
Raises ValueError if either artifact is missing required keys.
|
|
47
51
|
"""
|
|
48
52
|
|
|
49
53
|
def pct_delta(curr_val: float, base_val: float) -> float:
|
|
@@ -51,6 +55,24 @@ def compute_comparison(current: dict, baseline: dict) -> dict:
|
|
|
51
55
|
return 0.0 if curr_val == 0 else 100.0
|
|
52
56
|
return round(((curr_val - base_val) / base_val) * 100, 2)
|
|
53
57
|
|
|
58
|
+
# Validate required structure
|
|
59
|
+
for label, artifact in [("current", current), ("baseline", baseline)]:
|
|
60
|
+
for key in ["summary", "latency_percentiles", "metadata"]:
|
|
61
|
+
if key not in artifact:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Invalid {label} artifact: missing required key '{key}'"
|
|
64
|
+
)
|
|
65
|
+
for subkey in ["rps", "total_requests", "failed_requests"]:
|
|
66
|
+
if subkey not in artifact["summary"]:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Invalid {label} artifact: missing 'summary.{subkey}'"
|
|
69
|
+
)
|
|
70
|
+
for subkey in ["p95_us"]:
|
|
71
|
+
if subkey not in artifact["latency_percentiles"]:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Invalid {label} artifact: missing 'latency_percentiles.{subkey}'"
|
|
74
|
+
)
|
|
75
|
+
|
|
54
76
|
curr_summary = current["summary"]
|
|
55
77
|
base_summary = baseline["summary"]
|
|
56
78
|
curr_lp = current["latency_percentiles"]
|
|
@@ -74,10 +96,10 @@ def compute_comparison(current: dict, baseline: dict) -> dict:
|
|
|
74
96
|
"rps_delta": pct_delta(curr_summary["rps"], base_summary["rps"]),
|
|
75
97
|
"error_rate_delta": round(curr_error_rate - base_error_rate, 2),
|
|
76
98
|
"baseline_rps": base_summary["rps"],
|
|
77
|
-
"baseline_p95_ms":
|
|
99
|
+
"baseline_p95_ms": us_to_ms(base_lp["p95_us"]),
|
|
78
100
|
"baseline_error_rate": round(base_error_rate, 2),
|
|
79
101
|
"current_rps": curr_summary["rps"],
|
|
80
|
-
"current_p95_ms":
|
|
102
|
+
"current_p95_ms": us_to_ms(curr_lp["p95_us"]),
|
|
81
103
|
"current_error_rate": round(curr_error_rate, 2),
|
|
82
104
|
}
|
|
83
105
|
|
|
@@ -192,12 +192,14 @@ def render_html_report(summary, config, comparison=None) -> str:
|
|
|
192
192
|
artifact = build_artifact_dict(summary, config)
|
|
193
193
|
|
|
194
194
|
# Convert us -> ms for chart display
|
|
195
|
+
from strobengine.reporting import us_to_ms
|
|
196
|
+
|
|
195
197
|
lp = artifact["latency_percentiles"]
|
|
196
198
|
latency_ms = {
|
|
197
|
-
"p50":
|
|
198
|
-
"p90":
|
|
199
|
-
"p95":
|
|
200
|
-
"p99":
|
|
199
|
+
"p50": us_to_ms(lp.get("p50_us")),
|
|
200
|
+
"p90": us_to_ms(lp.get("p90_us")),
|
|
201
|
+
"p95": us_to_ms(lp.get("p95_us")),
|
|
202
|
+
"p99": us_to_ms(lp.get("p99_us")),
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
# Group status codes by class
|
|
@@ -24,7 +24,9 @@ def generate_junit_report(artifact: dict) -> str:
|
|
|
24
24
|
failed = s["failed_requests"]
|
|
25
25
|
total = s["total_requests"]
|
|
26
26
|
error_rate = (failed / total * 100) if total > 0 else 0.0
|
|
27
|
-
|
|
27
|
+
from strobengine.reporting import us_to_ms
|
|
28
|
+
|
|
29
|
+
p95_ms = us_to_ms(lp["p95_us"])
|
|
28
30
|
|
|
29
31
|
testsuites = Element("testsuites")
|
|
30
32
|
testsuite = SubElement(
|
|
@@ -48,9 +50,9 @@ def generate_junit_report(artifact: dict) -> str:
|
|
|
48
50
|
so1 = SubElement(tc1, "system-out")
|
|
49
51
|
so1.text = (
|
|
50
52
|
f"RPS: {s['rps']:.2f}, "
|
|
51
|
-
f"P50: {lp['p50_us']
|
|
52
|
-
f"P95: {p95_ms
|
|
53
|
-
f"P99: {lp['p99_us']
|
|
53
|
+
f"P50: {us_to_ms(lp['p50_us'])}ms, "
|
|
54
|
+
f"P95: {p95_ms}ms, "
|
|
55
|
+
f"P99: {us_to_ms(lp['p99_us'])}ms, "
|
|
54
56
|
f"Errors: {error_rate:.2f}%, "
|
|
55
57
|
f"Total: {total:,}"
|
|
56
58
|
)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from strobengine.reporter import build_artifact_dict
|
|
8
|
+
from strobengine.reporting import us_to_ms
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def generate_markdown_summary(artifact: dict) -> str:
|
|
@@ -43,10 +44,10 @@ def generate_markdown_summary(artifact: dict) -> str:
|
|
|
43
44
|
f"| Successful | {s['successful_requests']:,} |",
|
|
44
45
|
f"| Failed | {failed:,} |",
|
|
45
46
|
f"| Requests/sec | {s['rps']:.2f} |",
|
|
46
|
-
f"| P50 Latency | {lp['p50_us']
|
|
47
|
-
f"| P90 Latency | {lp['p90_us']
|
|
48
|
-
f"| P95 Latency | {lp['p95_us']
|
|
49
|
-
f"| P99 Latency | {lp['p99_us']
|
|
47
|
+
f"| P50 Latency | {us_to_ms(lp['p50_us'])} ms |",
|
|
48
|
+
f"| P90 Latency | {us_to_ms(lp['p90_us'])} ms |",
|
|
49
|
+
f"| P95 Latency | {us_to_ms(lp['p95_us'])} ms |",
|
|
50
|
+
f"| P99 Latency | {us_to_ms(lp['p99_us'])} ms |",
|
|
50
51
|
f"| Error Rate | {error_rate:.2f}% |",
|
|
51
52
|
"",
|
|
52
53
|
]
|
|
@@ -5,6 +5,17 @@ from unittest.mock import Mock
|
|
|
5
5
|
import pytest
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
|
+
from strobengine.cli import _parse_headers, _validate_method
|
|
9
|
+
from strobengine.reporter import build_artifact_dict
|
|
10
|
+
from strobengine.reporting.baseline import compute_comparison, load_baseline_artifact
|
|
11
|
+
from strobengine.reporting.csv_report import generate_csv_report, save_csv_report
|
|
12
|
+
from strobengine.reporting.html_report import render_html_report, save_html_report
|
|
13
|
+
from strobengine.reporting.junit_report import generate_junit_report
|
|
14
|
+
from strobengine.reporting.markdown_report import (
|
|
15
|
+
generate_markdown_summary,
|
|
16
|
+
save_markdown_report,
|
|
17
|
+
)
|
|
18
|
+
|
|
8
19
|
|
|
9
20
|
def _make_summary(**kwargs):
|
|
10
21
|
"""Create a mock TestSummary with sensible defaults."""
|
|
@@ -57,18 +68,12 @@ class TestMarkdownSummary:
|
|
|
57
68
|
"""Tests for generate_markdown_summary."""
|
|
58
69
|
|
|
59
70
|
def test_pass_badge_when_no_errors(self):
|
|
60
|
-
from strobengine.reporter import build_artifact_dict
|
|
61
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
62
|
-
|
|
63
71
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
64
72
|
md = generate_markdown_summary(artifact)
|
|
65
73
|
assert "PASS" in md
|
|
66
74
|
assert "brightgreen" in md
|
|
67
75
|
|
|
68
76
|
def test_fail_badge_when_errors(self):
|
|
69
|
-
from strobengine.reporter import build_artifact_dict
|
|
70
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
71
|
-
|
|
72
77
|
artifact = build_artifact_dict(
|
|
73
78
|
_make_summary(total_errors=5, status_codes={200: 95, 500: 5}),
|
|
74
79
|
_make_config(),
|
|
@@ -78,9 +83,6 @@ class TestMarkdownSummary:
|
|
|
78
83
|
assert "red" in md
|
|
79
84
|
|
|
80
85
|
def test_has_metrics_table(self):
|
|
81
|
-
from strobengine.reporter import build_artifact_dict
|
|
82
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
83
|
-
|
|
84
86
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
85
87
|
md = generate_markdown_summary(artifact)
|
|
86
88
|
assert "P95 Latency" in md
|
|
@@ -88,9 +90,6 @@ class TestMarkdownSummary:
|
|
|
88
90
|
assert "Total Requests" in md
|
|
89
91
|
|
|
90
92
|
def test_error_details_collapsible(self):
|
|
91
|
-
from strobengine.reporter import build_artifact_dict
|
|
92
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
93
|
-
|
|
94
93
|
artifact = build_artifact_dict(
|
|
95
94
|
_make_summary(total_errors=5, status_codes={200: 95, 500: 5}),
|
|
96
95
|
_make_config(),
|
|
@@ -101,17 +100,11 @@ class TestMarkdownSummary:
|
|
|
101
100
|
assert "</details>" in md
|
|
102
101
|
|
|
103
102
|
def test_no_errors_no_details_section(self):
|
|
104
|
-
from strobengine.reporter import build_artifact_dict
|
|
105
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
106
|
-
|
|
107
103
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
108
104
|
md = generate_markdown_summary(artifact)
|
|
109
105
|
assert "<details>" not in md
|
|
110
106
|
|
|
111
107
|
def test_zero_requests_no_crash(self):
|
|
112
|
-
from strobengine.reporter import build_artifact_dict
|
|
113
|
-
from strobengine.reporting.markdown_report import generate_markdown_summary
|
|
114
|
-
|
|
115
108
|
artifact = build_artifact_dict(
|
|
116
109
|
_make_summary(total_requests=0, total_errors=0, status_codes={}),
|
|
117
110
|
_make_config(),
|
|
@@ -125,8 +118,6 @@ class TestMarkdownReportFile:
|
|
|
125
118
|
"""Tests for save_markdown_report file output."""
|
|
126
119
|
|
|
127
120
|
def test_save_markdown_report(self, tmp_path):
|
|
128
|
-
from strobengine.reporting.markdown_report import save_markdown_report
|
|
129
|
-
|
|
130
121
|
filepath = str(tmp_path / "report.md")
|
|
131
122
|
result = save_markdown_report(_make_summary(), _make_config(), filepath, 10.0)
|
|
132
123
|
assert result == filepath
|
|
@@ -135,8 +126,6 @@ class TestMarkdownReportFile:
|
|
|
135
126
|
assert "PASS" in content
|
|
136
127
|
|
|
137
128
|
def test_save_markdown_report_expands_user(self, tmp_path, monkeypatch):
|
|
138
|
-
from strobengine.reporting.markdown_report import save_markdown_report
|
|
139
|
-
|
|
140
129
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
141
130
|
result = save_markdown_report(
|
|
142
131
|
_make_summary(), _make_config(), "~/test_report.md", 10.0
|
|
@@ -148,9 +137,6 @@ class TestJUnitReport:
|
|
|
148
137
|
"""Tests for generate_junit_report."""
|
|
149
138
|
|
|
150
139
|
def test_generate_junit_xml_structure(self):
|
|
151
|
-
from strobengine.reporter import build_artifact_dict
|
|
152
|
-
from strobengine.reporting.junit_report import generate_junit_report
|
|
153
|
-
|
|
154
140
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
155
141
|
xml = generate_junit_report(artifact)
|
|
156
142
|
assert "<testsuites>" in xml
|
|
@@ -158,9 +144,6 @@ class TestJUnitReport:
|
|
|
158
144
|
assert "<testcase" in xml
|
|
159
145
|
|
|
160
146
|
def test_generate_junit_failure_when_errors(self):
|
|
161
|
-
from strobengine.reporter import build_artifact_dict
|
|
162
|
-
from strobengine.reporting.junit_report import generate_junit_report
|
|
163
|
-
|
|
164
147
|
# 5 errors out of 100 = 5% > 1% threshold
|
|
165
148
|
# Triggers failures on both load_test and error_rate_threshold testcases
|
|
166
149
|
artifact = build_artifact_dict(
|
|
@@ -172,18 +155,12 @@ class TestJUnitReport:
|
|
|
172
155
|
assert "performance_regression" in xml
|
|
173
156
|
|
|
174
157
|
def test_generate_junit_no_failure_when_clean(self):
|
|
175
|
-
from strobengine.reporter import build_artifact_dict
|
|
176
|
-
from strobengine.reporting.junit_report import generate_junit_report
|
|
177
|
-
|
|
178
158
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
179
159
|
xml = generate_junit_report(artifact)
|
|
180
160
|
assert 'failures="0"' in xml
|
|
181
161
|
assert "<failure" not in xml
|
|
182
162
|
|
|
183
163
|
def test_generate_junit_error_rate_threshold(self):
|
|
184
|
-
from strobengine.reporter import build_artifact_dict
|
|
185
|
-
from strobengine.reporting.junit_report import generate_junit_report
|
|
186
|
-
|
|
187
164
|
# 5 errors out of 100 = 5% > 1% threshold
|
|
188
165
|
artifact = build_artifact_dict(
|
|
189
166
|
_make_summary(total_errors=5, status_codes={200: 95, 500: 5}),
|
|
@@ -194,9 +171,6 @@ class TestJUnitReport:
|
|
|
194
171
|
assert "threshold_breach" in xml
|
|
195
172
|
|
|
196
173
|
def test_generate_junit_p95_threshold(self):
|
|
197
|
-
from strobengine.reporter import build_artifact_dict
|
|
198
|
-
from strobengine.reporting.junit_report import generate_junit_report
|
|
199
|
-
|
|
200
174
|
# P95 = 150ms > 100ms threshold
|
|
201
175
|
artifact = build_artifact_dict(
|
|
202
176
|
_make_summary(p95_latency_ms=150.0),
|
|
@@ -211,18 +185,12 @@ class TestCSVReport:
|
|
|
211
185
|
"""Tests for generate_csv_report."""
|
|
212
186
|
|
|
213
187
|
def test_generate_csv_has_header(self):
|
|
214
|
-
from strobengine.reporter import build_artifact_dict
|
|
215
|
-
from strobengine.reporting.csv_report import generate_csv_report
|
|
216
|
-
|
|
217
188
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
218
189
|
csv_output = generate_csv_report(artifact)
|
|
219
190
|
first_line = csv_output.strip().split("\n")[0]
|
|
220
191
|
assert first_line == "metric,value"
|
|
221
192
|
|
|
222
193
|
def test_generate_csv_has_all_fields(self):
|
|
223
|
-
from strobengine.reporter import build_artifact_dict
|
|
224
|
-
from strobengine.reporting.csv_report import generate_csv_report
|
|
225
|
-
|
|
226
194
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
227
195
|
csv_output = generate_csv_report(artifact)
|
|
228
196
|
assert "timestamp" in csv_output
|
|
@@ -234,9 +202,6 @@ class TestCSVReport:
|
|
|
234
202
|
assert "p99_us" in csv_output
|
|
235
203
|
|
|
236
204
|
def test_generate_csv_microsecond_latencies(self):
|
|
237
|
-
from strobengine.reporter import build_artifact_dict
|
|
238
|
-
from strobengine.reporting.csv_report import generate_csv_report
|
|
239
|
-
|
|
240
205
|
artifact = build_artifact_dict(_make_summary(), _make_config())
|
|
241
206
|
csv_output = generate_csv_report(artifact)
|
|
242
207
|
# p50 = 1.0 ms = 1000 us
|
|
@@ -245,8 +210,6 @@ class TestCSVReport:
|
|
|
245
210
|
assert "p95_us,3000" in csv_output
|
|
246
211
|
|
|
247
212
|
def test_generate_csv_file_output(self, tmp_path):
|
|
248
|
-
from strobengine.reporting.csv_report import save_csv_report
|
|
249
|
-
|
|
250
213
|
filepath = str(tmp_path / "report.csv")
|
|
251
214
|
result = save_csv_report(_make_summary(), _make_config(), filepath, 10.0)
|
|
252
215
|
assert result == filepath
|
|
@@ -259,7 +222,7 @@ class TestBaselineArtifact:
|
|
|
259
222
|
"""Tests for load_baseline_artifact."""
|
|
260
223
|
|
|
261
224
|
def test_load_baseline_from_file(self, tmp_path):
|
|
262
|
-
|
|
225
|
+
import json
|
|
263
226
|
|
|
264
227
|
artifact = {
|
|
265
228
|
"metadata": {
|
|
@@ -270,21 +233,17 @@ class TestBaselineArtifact:
|
|
|
270
233
|
"latency_percentiles": {"p95_us": 5000.0},
|
|
271
234
|
}
|
|
272
235
|
filepath = tmp_path / "report.json"
|
|
273
|
-
filepath.write_text(
|
|
236
|
+
filepath.write_text(json.dumps(artifact))
|
|
274
237
|
|
|
275
238
|
result = load_baseline_artifact(baseline_file=filepath)
|
|
276
239
|
assert result is not None
|
|
277
240
|
assert result["metadata"]["target_url"] == "http://test"
|
|
278
241
|
|
|
279
242
|
def test_load_baseline_file_not_found(self, tmp_path):
|
|
280
|
-
from strobengine.reporting.baseline import load_baseline_artifact
|
|
281
|
-
|
|
282
243
|
result = load_baseline_artifact(baseline_file=tmp_path / "nonexistent.json")
|
|
283
244
|
assert result is None
|
|
284
245
|
|
|
285
246
|
def test_load_baseline_corrupt_json(self, tmp_path):
|
|
286
|
-
from strobengine.reporting.baseline import load_baseline_artifact
|
|
287
|
-
|
|
288
247
|
filepath = tmp_path / "corrupt.json"
|
|
289
248
|
filepath.write_text("not valid json {{{")
|
|
290
249
|
|
|
@@ -292,7 +251,7 @@ class TestBaselineArtifact:
|
|
|
292
251
|
assert result is None
|
|
293
252
|
|
|
294
253
|
def test_load_baseline_latest_pointer(self, tmp_path):
|
|
295
|
-
|
|
254
|
+
import json
|
|
296
255
|
|
|
297
256
|
artifact = {
|
|
298
257
|
"metadata": {
|
|
@@ -303,29 +262,25 @@ class TestBaselineArtifact:
|
|
|
303
262
|
"latency_percentiles": {"p95_us": 5000.0},
|
|
304
263
|
}
|
|
305
264
|
report_file = tmp_path / "report_20260101_000000_test.json"
|
|
306
|
-
report_file.write_text(
|
|
265
|
+
report_file.write_text(json.dumps(artifact))
|
|
307
266
|
|
|
308
267
|
latest = tmp_path / "latest.json"
|
|
309
|
-
latest.write_text(
|
|
268
|
+
latest.write_text(json.dumps({"latest_report": report_file.name}))
|
|
310
269
|
|
|
311
270
|
result = load_baseline_artifact(report_dir=tmp_path)
|
|
312
271
|
assert result is not None
|
|
313
272
|
assert result["summary"]["rps"] == 10.0
|
|
314
273
|
|
|
315
274
|
def test_load_baseline_latest_missing_target(self, tmp_path):
|
|
316
|
-
|
|
275
|
+
import json
|
|
317
276
|
|
|
318
277
|
latest = tmp_path / "latest.json"
|
|
319
|
-
latest.write_text(
|
|
320
|
-
__import__("json").dumps({"latest_report": "nonexistent.json"})
|
|
321
|
-
)
|
|
278
|
+
latest.write_text(json.dumps({"latest_report": "nonexistent.json"}))
|
|
322
279
|
|
|
323
280
|
result = load_baseline_artifact(report_dir=tmp_path)
|
|
324
281
|
assert result is None
|
|
325
282
|
|
|
326
283
|
def test_load_baseline_no_latest_file(self, tmp_path):
|
|
327
|
-
from strobengine.reporting.baseline import load_baseline_artifact
|
|
328
|
-
|
|
329
284
|
result = load_baseline_artifact(report_dir=tmp_path)
|
|
330
285
|
assert result is None
|
|
331
286
|
|
|
@@ -348,8 +303,6 @@ class TestComputeComparison:
|
|
|
348
303
|
}
|
|
349
304
|
|
|
350
305
|
def test_compute_comparison_normal(self):
|
|
351
|
-
from strobengine.reporting.baseline import compute_comparison
|
|
352
|
-
|
|
353
306
|
current = self._make_artifact(
|
|
354
307
|
rps=40.0, total_requests=200, failed_requests=10, p95_us=25000.0
|
|
355
308
|
)
|
|
@@ -363,8 +316,6 @@ class TestComputeComparison:
|
|
|
363
316
|
assert result["error_rate_delta"] > 0 # regression (more errors)
|
|
364
317
|
|
|
365
318
|
def test_compute_comparison_improvement(self):
|
|
366
|
-
from strobengine.reporting.baseline import compute_comparison
|
|
367
|
-
|
|
368
319
|
current = self._make_artifact(
|
|
369
320
|
rps=50.0, total_requests=500, failed_requests=0, p95_us=20000.0
|
|
370
321
|
)
|
|
@@ -378,8 +329,6 @@ class TestComputeComparison:
|
|
|
378
329
|
assert result["error_rate_delta"] < 0 # errors improved
|
|
379
330
|
|
|
380
331
|
def test_compute_comparison_zero_baseline_rps(self):
|
|
381
|
-
from strobengine.reporting.baseline import compute_comparison
|
|
382
|
-
|
|
383
332
|
current = self._make_artifact(
|
|
384
333
|
rps=10.0, total_requests=100, failed_requests=0, p95_us=5000.0
|
|
385
334
|
)
|
|
@@ -391,8 +340,6 @@ class TestComputeComparison:
|
|
|
391
340
|
assert result["rps_delta"] == 100.0 # base=0, curr>0 -> 100%
|
|
392
341
|
|
|
393
342
|
def test_compute_comparison_zero_baseline_p95(self):
|
|
394
|
-
from strobengine.reporting.baseline import compute_comparison
|
|
395
|
-
|
|
396
343
|
current = self._make_artifact(
|
|
397
344
|
rps=10.0, total_requests=100, failed_requests=0, p95_us=10000.0
|
|
398
345
|
)
|
|
@@ -408,22 +355,16 @@ class TestHTMLReport:
|
|
|
408
355
|
"""Tests for render_html_report and save_html_report."""
|
|
409
356
|
|
|
410
357
|
def test_render_html_contains_charts(self):
|
|
411
|
-
from strobengine.reporting.html_report import render_html_report
|
|
412
|
-
|
|
413
358
|
html = render_html_report(_make_summary(), _make_config())
|
|
414
359
|
assert "<canvas" in html
|
|
415
360
|
assert "Chart" in html
|
|
416
361
|
|
|
417
362
|
def test_render_html_contains_metadata(self):
|
|
418
|
-
from strobengine.reporting.html_report import render_html_report
|
|
419
|
-
|
|
420
363
|
html = render_html_report(_make_summary(), _make_config())
|
|
421
364
|
assert "http://localhost:8080" in html
|
|
422
365
|
assert "10.0s" in html
|
|
423
366
|
|
|
424
367
|
def test_render_html_with_comparison(self):
|
|
425
|
-
from strobengine.reporting.html_report import render_html_report
|
|
426
|
-
|
|
427
368
|
comparison = {
|
|
428
369
|
"baseline_timestamp": "2026-01-01T00:00:00Z",
|
|
429
370
|
"rps_delta": 10.5,
|
|
@@ -439,14 +380,10 @@ class TestHTMLReport:
|
|
|
439
380
|
assert "Historical Comparison" in html
|
|
440
381
|
|
|
441
382
|
def test_render_html_without_comparison(self):
|
|
442
|
-
from strobengine.reporting.html_report import render_html_report
|
|
443
|
-
|
|
444
383
|
html = render_html_report(_make_summary(), _make_config(), comparison=None)
|
|
445
384
|
assert "Historical Comparison" not in html
|
|
446
385
|
|
|
447
386
|
def test_render_html_status_grouping(self):
|
|
448
|
-
from strobengine.reporting.html_report import render_html_report
|
|
449
|
-
|
|
450
387
|
summary = _make_summary(total_errors=10, status_codes={200: 90, 404: 5, 500: 5})
|
|
451
388
|
html = render_html_report(summary, _make_config())
|
|
452
389
|
assert "2xx" in html
|
|
@@ -454,8 +391,6 @@ class TestHTMLReport:
|
|
|
454
391
|
assert "5xx" in html
|
|
455
392
|
|
|
456
393
|
def test_save_html_report_file_output(self, tmp_path):
|
|
457
|
-
from strobengine.reporting.html_report import save_html_report
|
|
458
|
-
|
|
459
394
|
filepath = str(tmp_path / "report.html")
|
|
460
395
|
result = save_html_report(_make_summary(), _make_config(), filepath)
|
|
461
396
|
assert result == filepath
|
|
@@ -468,25 +403,17 @@ class TestCLIHelpers:
|
|
|
468
403
|
"""Tests for cli.py helper functions."""
|
|
469
404
|
|
|
470
405
|
def test_parse_headers_valid(self):
|
|
471
|
-
from strobengine.cli import _parse_headers
|
|
472
|
-
|
|
473
406
|
result = _parse_headers(["X-Custom: value"])
|
|
474
407
|
assert result == [("X-Custom", "value")]
|
|
475
408
|
|
|
476
409
|
def test_parse_headers_invalid(self):
|
|
477
|
-
from strobengine.cli import _parse_headers
|
|
478
|
-
|
|
479
410
|
with pytest.raises(typer.BadParameter):
|
|
480
411
|
_parse_headers(["NoColonHere"])
|
|
481
412
|
|
|
482
413
|
def test_validate_method_uppercase(self):
|
|
483
|
-
from strobengine.cli import _validate_method
|
|
484
|
-
|
|
485
414
|
assert _validate_method("get") == "GET"
|
|
486
415
|
assert _validate_method("post") == "POST"
|
|
487
416
|
|
|
488
417
|
def test_validate_method_invalid(self):
|
|
489
|
-
from strobengine.cli import _validate_method
|
|
490
|
-
|
|
491
418
|
with pytest.raises(typer.BadParameter):
|
|
492
419
|
_validate_method("INVALID")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|