strobengine 0.5.0__tar.gz → 0.5.1__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.0 → strobengine-0.5.1}/CHANGELOG.md +18 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/Cargo.lock +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/Cargo.toml +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/PKG-INFO +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/pyproject.toml +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/http.rs +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/websocket.rs +19 -7
- {strobengine-0.5.0 → strobengine-0.5.1}/src/report/writer.rs +31 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/baseline.py +3 -3
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/markdown_report.py +0 -57
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_reporting.py +240 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/uv.lock +1 -1
- {strobengine-0.5.0 → strobengine-0.5.1}/.dockerignore +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/ci.yml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/docker-release.yml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/publish-test.yml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/publish.yml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.gitignore +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.pre-commit-config.yaml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/.python-version +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/Dockerfile +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/LICENSE +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/Makefile +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/README.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/cliff.toml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/benchmarks.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/cli.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/dependencies.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/docker.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/grpc.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/http3.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/http_methods.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/infrastructure.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/install.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/quickstart.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/release.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/reports.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/roadmap.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/sse.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/table_of_contents.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/testing.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/docs/websockets.md +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/grpc.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/http3.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/http_methods.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/load_test.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/spike_test.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/sse.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/stress_test.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/examples/websocket.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/rust-toolchain.toml +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/scripts/release.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/chaos.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/config.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/lib.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/logging.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/metrics.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/progress.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc_parser.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc_reflection.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/http3.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/mod.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/sse.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/report/mod.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/report/schema.rs +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/__init__.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/_strobengine.pyi +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/cli.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/constants.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/engine.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/py.typed +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporter.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/__init__.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/assets/chart.min.js +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/csv_report.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/html_report.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/junit_report.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/__init__.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/conftest.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/__init__.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/conftest.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/mock_server.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_cli_interface.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_grpc_e2e.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_http3_e2e.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_http_scenarios.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_performance_sanity.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_persistence_e2e.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_sse_e2e.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_websocket_e2e.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/fixtures/service.proto +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_cli.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_engine.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_logging.py +0 -0
- {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_reporter.py +0 -0
|
@@ -4,6 +4,23 @@ 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.1] - 2026-09-01
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- *(websocket)* Use user-configured timeout for persistent session read
|
|
12
|
+
- *(http)* Use user-configured method for CorruptedPayload chaos fault
|
|
13
|
+
- *(report)* Handle Windows fs::rename target collision in writer
|
|
14
|
+
- *(reporting)* Move rich import inside print_cli_comparison
|
|
15
|
+
- *(websocket)* Warn on invalid WebSocket headers instead of silent drop
|
|
16
|
+
|
|
17
|
+
### Refactoring
|
|
18
|
+
|
|
19
|
+
- *(reporting)* Remove dead render_markdown_report function
|
|
20
|
+
|
|
21
|
+
### Testing
|
|
22
|
+
|
|
23
|
+
- *(reporting)* Add unit tests for baseline, html_report, and cli helpers
|
|
7
24
|
## [0.5.0] - 2026-08-30
|
|
8
25
|
|
|
9
26
|
### Documentation
|
|
@@ -51,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
51
68
|
|
|
52
69
|
- *(pyproject)* Simplify readme config and update documentation URL
|
|
53
70
|
- *(scripts)* Run uv lock during release version bump
|
|
71
|
+
- *(release)* Bump version to 0.5.0
|
|
54
72
|
|
|
55
73
|
### Styling
|
|
56
74
|
|
|
@@ -97,7 +97,7 @@ impl ProtocolEngine for HttpEngine {
|
|
|
97
97
|
Some(ChaosFault::CorruptedPayload) => {
|
|
98
98
|
tracing::trace!("chaos: corrupted payload injected");
|
|
99
99
|
self.client
|
|
100
|
-
.
|
|
100
|
+
.request(self.method.clone(), target_url)
|
|
101
101
|
.header(CHAOS_HEADER, "corrupted-payload")
|
|
102
102
|
.body(CORRUPTED_BODY)
|
|
103
103
|
}
|
|
@@ -97,11 +97,16 @@ impl PersistentWsSession {
|
|
|
97
97
|
.map_err(|e| EngineError::ConnectionFailed(e.to_string()))?;
|
|
98
98
|
|
|
99
99
|
for (key, value) in &self.headers {
|
|
100
|
-
|
|
100
|
+
match (
|
|
101
101
|
key.as_str().parse::<http::header::HeaderName>(),
|
|
102
102
|
value.as_str().parse(),
|
|
103
103
|
) {
|
|
104
|
-
|
|
104
|
+
(Ok(name), Ok(val)) => {
|
|
105
|
+
request.headers_mut().insert(name, val);
|
|
106
|
+
}
|
|
107
|
+
_ => {
|
|
108
|
+
tracing::warn!(key = %key, "invalid WebSocket header skipped");
|
|
109
|
+
}
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -136,7 +141,11 @@ impl PersistentWsSession {
|
|
|
136
141
|
self.messages_sent += 1;
|
|
137
142
|
|
|
138
143
|
// Read response with timeout
|
|
139
|
-
let timeout = Duration::from_secs(
|
|
144
|
+
let timeout = Duration::from_secs(if self.timeout_secs > 0 {
|
|
145
|
+
self.timeout_secs
|
|
146
|
+
} else {
|
|
147
|
+
5
|
|
148
|
+
});
|
|
140
149
|
let read_result = tokio::time::timeout(timeout, async {
|
|
141
150
|
let mut response_bytes = Vec::new();
|
|
142
151
|
while let Some(Ok(msg)) = stream.next().await {
|
|
@@ -283,10 +292,13 @@ impl WebSocketEngine {
|
|
|
283
292
|
.map_err(|e| EngineError::ConnectionFailed(e.to_string()))?;
|
|
284
293
|
|
|
285
294
|
for (key, value) in headers {
|
|
286
|
-
|
|
287
|
-
(
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
match (key.as_str().parse::<HeaderName>(), value.as_str().parse()) {
|
|
296
|
+
(Ok(name), Ok(val)) => {
|
|
297
|
+
request.headers_mut().insert(name, val);
|
|
298
|
+
}
|
|
299
|
+
_ => {
|
|
300
|
+
tracing::warn!(key = %key, "invalid WebSocket header skipped");
|
|
301
|
+
}
|
|
290
302
|
}
|
|
291
303
|
}
|
|
292
304
|
|
|
@@ -57,6 +57,10 @@ pub fn save_report_json(
|
|
|
57
57
|
// Atomic write: serialize → write tmp → rename to final
|
|
58
58
|
let json_bytes = serde_json::to_vec_pretty(artifact)?;
|
|
59
59
|
fs::write(&tmp_path, &json_bytes)?;
|
|
60
|
+
|
|
61
|
+
// Pre-emptively clear target for cross-platform compatibility (Windows)
|
|
62
|
+
let _ = fs::remove_file(&target_path);
|
|
63
|
+
|
|
60
64
|
fs::rename(&tmp_path, &target_path)?;
|
|
61
65
|
|
|
62
66
|
// Update latest.json pointer (also atomic)
|
|
@@ -74,6 +78,10 @@ fn update_latest_pointer(dir: &Path, filename: &str) -> Result<(), io::Error> {
|
|
|
74
78
|
let json_bytes = serde_json::to_vec_pretty(&payload)?;
|
|
75
79
|
|
|
76
80
|
fs::write(&latest_tmp, &json_bytes)?;
|
|
81
|
+
|
|
82
|
+
// Pre-emptively clear existing latest.json for cross-platform compatibility (Windows)
|
|
83
|
+
let _ = fs::remove_file(&latest_path);
|
|
84
|
+
|
|
77
85
|
fs::rename(&latest_tmp, &latest_path)?;
|
|
78
86
|
|
|
79
87
|
Ok(())
|
|
@@ -242,4 +250,27 @@ mod tests {
|
|
|
242
250
|
);
|
|
243
251
|
fs::remove_dir_all("./.strobengine").ok();
|
|
244
252
|
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn test_save_report_json_overwrites_existing_target() {
|
|
256
|
+
let dir = std::env::temp_dir().join("strobengine_test_overwrite");
|
|
257
|
+
let artifact = sample_artifact();
|
|
258
|
+
|
|
259
|
+
// First write — creates the file
|
|
260
|
+
let path1 = save_report_json(&artifact, Some(&dir), false).unwrap();
|
|
261
|
+
assert!(path1.exists());
|
|
262
|
+
let content1 = fs::read_to_string(&path1).unwrap();
|
|
263
|
+
assert!(content1.contains("metadata"));
|
|
264
|
+
|
|
265
|
+
// Second write — should overwrite (simulates same-second collision)
|
|
266
|
+
let path2 = save_report_json(&artifact, Some(&dir), false).unwrap();
|
|
267
|
+
assert!(path2.exists());
|
|
268
|
+
let content2 = fs::read_to_string(&path2).unwrap();
|
|
269
|
+
assert!(content2.contains("metadata"));
|
|
270
|
+
|
|
271
|
+
// Both paths should be the same (same filename)
|
|
272
|
+
assert_eq!(path1, path2);
|
|
273
|
+
|
|
274
|
+
fs::remove_dir_all(&dir).ok();
|
|
275
|
+
}
|
|
245
276
|
}
|
|
@@ -6,9 +6,6 @@ import json
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
-
from rich.console import Console
|
|
10
|
-
from rich.table import Table
|
|
11
|
-
|
|
12
9
|
|
|
13
10
|
def load_baseline_artifact(
|
|
14
11
|
report_dir: Path = Path(".strobengine/reports"),
|
|
@@ -87,6 +84,9 @@ def compute_comparison(current: dict, baseline: dict) -> dict:
|
|
|
87
84
|
|
|
88
85
|
def print_cli_comparison(comparison: dict) -> None:
|
|
89
86
|
"""Print baseline comparison table to terminal using Rich."""
|
|
87
|
+
from rich.console import Console
|
|
88
|
+
from rich.table import Table
|
|
89
|
+
|
|
90
90
|
console = Console()
|
|
91
91
|
table = Table(title="Historical Comparison", show_lines=True, padding=(0, 1))
|
|
92
92
|
table.add_column("Metric", style="bold cyan", no_wrap=True)
|
|
@@ -77,63 +77,6 @@ def generate_markdown_summary(artifact: dict) -> str:
|
|
|
77
77
|
return "\n".join(lines)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def render_markdown_report(summary, config, duration_secs: float) -> str:
|
|
81
|
-
"""Render a Markdown report from TestSummary (full format with all details)."""
|
|
82
|
-
artifact = build_artifact_dict(summary, config)
|
|
83
|
-
meta = artifact["metadata"]
|
|
84
|
-
s = artifact["summary"]
|
|
85
|
-
lp = artifact["latency_percentiles"]
|
|
86
|
-
|
|
87
|
-
duration = duration_secs or meta.get("duration_secs", 0)
|
|
88
|
-
error_rate = (
|
|
89
|
-
(s["failed_requests"] / s["total_requests"] * 100)
|
|
90
|
-
if s["total_requests"] > 0
|
|
91
|
-
else 0.0
|
|
92
|
-
)
|
|
93
|
-
bytes_kb = s["bytes_transferred"] / 1024
|
|
94
|
-
|
|
95
|
-
lines = [
|
|
96
|
-
"# Load Test Results",
|
|
97
|
-
"",
|
|
98
|
-
"| Metric | Value |",
|
|
99
|
-
"|--------|-------|",
|
|
100
|
-
f"| Target URL | `{meta['target_url']}` |",
|
|
101
|
-
f"| Timestamp | {meta['timestamp']} |",
|
|
102
|
-
f"| Duration | {duration:.1f}s |",
|
|
103
|
-
f"| Concurrency | {meta['cli_options']['concurrency']} |",
|
|
104
|
-
f"| Method | {meta['cli_options']['method']} |",
|
|
105
|
-
f"| Total Requests | {s['total_requests']:,} |",
|
|
106
|
-
f"| Successful | {s['successful_requests']:,} |",
|
|
107
|
-
f"| Failed | {s['failed_requests']:,} |",
|
|
108
|
-
f"| Requests/sec | {s['rps']:.2f} |",
|
|
109
|
-
f"| P50 Latency | {lp['p50_us'] / 1000:.2f} ms |",
|
|
110
|
-
f"| P90 Latency | {lp['p90_us'] / 1000:.2f} ms |",
|
|
111
|
-
f"| P95 Latency | {lp['p95_us'] / 1000:.2f} ms |",
|
|
112
|
-
f"| P99 Latency | {lp['p99_us'] / 1000:.2f} ms |",
|
|
113
|
-
f"| Min Latency | {lp['min_us'] / 1000:.2f} ms |",
|
|
114
|
-
f"| Max Latency | {lp['max_us'] / 1000:.2f} ms |",
|
|
115
|
-
f"| Mean Latency | {lp['mean_us'] / 1000:.2f} ms |",
|
|
116
|
-
f"| Error Rate | {error_rate:.2f}% |",
|
|
117
|
-
f"| Bytes Transferred | {bytes_kb:.1f} KB |",
|
|
118
|
-
"",
|
|
119
|
-
]
|
|
120
|
-
|
|
121
|
-
# Status code breakdown
|
|
122
|
-
if artifact["error_breakdown"]:
|
|
123
|
-
lines.append("## Status Codes")
|
|
124
|
-
lines.append("")
|
|
125
|
-
lines.append("| Code | Count |")
|
|
126
|
-
lines.append("|------|-------|")
|
|
127
|
-
for code, count in sorted(
|
|
128
|
-
artifact["error_breakdown"].items(),
|
|
129
|
-
key=lambda x: int(x[0]) if str(x[0]).isdigit() else 0,
|
|
130
|
-
):
|
|
131
|
-
lines.append(f"| {code} | {count:,} |")
|
|
132
|
-
lines.append("")
|
|
133
|
-
|
|
134
|
-
return "\n".join(lines)
|
|
135
|
-
|
|
136
|
-
|
|
137
80
|
def save_markdown_report(summary, config, filepath: str, duration_secs: float) -> str:
|
|
138
81
|
"""Render and write Markdown report to disk. Returns filepath."""
|
|
139
82
|
filepath = str(Path(filepath).expanduser().resolve())
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from unittest.mock import Mock
|
|
4
4
|
|
|
5
|
+
import pytest
|
|
6
|
+
import typer
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
def _make_summary(**kwargs):
|
|
7
10
|
"""Create a mock TestSummary with sensible defaults."""
|
|
@@ -250,3 +253,240 @@ class TestCSVReport:
|
|
|
250
253
|
assert (tmp_path / "report.csv").exists()
|
|
251
254
|
content = (tmp_path / "report.csv").read_text()
|
|
252
255
|
assert "metric,value" in content
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestBaselineArtifact:
|
|
259
|
+
"""Tests for load_baseline_artifact."""
|
|
260
|
+
|
|
261
|
+
def test_load_baseline_from_file(self, tmp_path):
|
|
262
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
263
|
+
|
|
264
|
+
artifact = {
|
|
265
|
+
"metadata": {
|
|
266
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
267
|
+
"target_url": "http://test",
|
|
268
|
+
},
|
|
269
|
+
"summary": {"rps": 10.0, "total_requests": 100, "failed_requests": 0},
|
|
270
|
+
"latency_percentiles": {"p95_us": 5000.0},
|
|
271
|
+
}
|
|
272
|
+
filepath = tmp_path / "report.json"
|
|
273
|
+
filepath.write_text(__import__("json").dumps(artifact))
|
|
274
|
+
|
|
275
|
+
result = load_baseline_artifact(baseline_file=filepath)
|
|
276
|
+
assert result is not None
|
|
277
|
+
assert result["metadata"]["target_url"] == "http://test"
|
|
278
|
+
|
|
279
|
+
def test_load_baseline_file_not_found(self, tmp_path):
|
|
280
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
281
|
+
|
|
282
|
+
result = load_baseline_artifact(baseline_file=tmp_path / "nonexistent.json")
|
|
283
|
+
assert result is None
|
|
284
|
+
|
|
285
|
+
def test_load_baseline_corrupt_json(self, tmp_path):
|
|
286
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
287
|
+
|
|
288
|
+
filepath = tmp_path / "corrupt.json"
|
|
289
|
+
filepath.write_text("not valid json {{{")
|
|
290
|
+
|
|
291
|
+
result = load_baseline_artifact(baseline_file=filepath)
|
|
292
|
+
assert result is None
|
|
293
|
+
|
|
294
|
+
def test_load_baseline_latest_pointer(self, tmp_path):
|
|
295
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
296
|
+
|
|
297
|
+
artifact = {
|
|
298
|
+
"metadata": {
|
|
299
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
300
|
+
"target_url": "http://test",
|
|
301
|
+
},
|
|
302
|
+
"summary": {"rps": 10.0, "total_requests": 100, "failed_requests": 0},
|
|
303
|
+
"latency_percentiles": {"p95_us": 5000.0},
|
|
304
|
+
}
|
|
305
|
+
report_file = tmp_path / "report_20260101_000000_test.json"
|
|
306
|
+
report_file.write_text(__import__("json").dumps(artifact))
|
|
307
|
+
|
|
308
|
+
latest = tmp_path / "latest.json"
|
|
309
|
+
latest.write_text(__import__("json").dumps({"latest_report": report_file.name}))
|
|
310
|
+
|
|
311
|
+
result = load_baseline_artifact(report_dir=tmp_path)
|
|
312
|
+
assert result is not None
|
|
313
|
+
assert result["summary"]["rps"] == 10.0
|
|
314
|
+
|
|
315
|
+
def test_load_baseline_latest_missing_target(self, tmp_path):
|
|
316
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
317
|
+
|
|
318
|
+
latest = tmp_path / "latest.json"
|
|
319
|
+
latest.write_text(
|
|
320
|
+
__import__("json").dumps({"latest_report": "nonexistent.json"})
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
result = load_baseline_artifact(report_dir=tmp_path)
|
|
324
|
+
assert result is None
|
|
325
|
+
|
|
326
|
+
def test_load_baseline_no_latest_file(self, tmp_path):
|
|
327
|
+
from strobengine.reporting.baseline import load_baseline_artifact
|
|
328
|
+
|
|
329
|
+
result = load_baseline_artifact(report_dir=tmp_path)
|
|
330
|
+
assert result is None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class TestComputeComparison:
|
|
334
|
+
"""Tests for compute_comparison."""
|
|
335
|
+
|
|
336
|
+
def _make_artifact(self, rps, total_requests, failed_requests, p95_us):
|
|
337
|
+
return {
|
|
338
|
+
"metadata": {
|
|
339
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
340
|
+
"target_url": "http://test",
|
|
341
|
+
},
|
|
342
|
+
"summary": {
|
|
343
|
+
"total_requests": total_requests,
|
|
344
|
+
"failed_requests": failed_requests,
|
|
345
|
+
"rps": rps,
|
|
346
|
+
},
|
|
347
|
+
"latency_percentiles": {"p95_us": p95_us},
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def test_compute_comparison_normal(self):
|
|
351
|
+
from strobengine.reporting.baseline import compute_comparison
|
|
352
|
+
|
|
353
|
+
current = self._make_artifact(
|
|
354
|
+
rps=40.0, total_requests=200, failed_requests=10, p95_us=25000.0
|
|
355
|
+
)
|
|
356
|
+
baseline = self._make_artifact(
|
|
357
|
+
rps=30.0, total_requests=150, failed_requests=5, p95_us=35000.0
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
result = compute_comparison(current, baseline)
|
|
361
|
+
assert result["rps_delta"] == 33.33
|
|
362
|
+
assert result["latency_p95_delta"] < 0 # improvement (lower latency)
|
|
363
|
+
assert result["error_rate_delta"] > 0 # regression (more errors)
|
|
364
|
+
|
|
365
|
+
def test_compute_comparison_improvement(self):
|
|
366
|
+
from strobengine.reporting.baseline import compute_comparison
|
|
367
|
+
|
|
368
|
+
current = self._make_artifact(
|
|
369
|
+
rps=50.0, total_requests=500, failed_requests=0, p95_us=20000.0
|
|
370
|
+
)
|
|
371
|
+
baseline = self._make_artifact(
|
|
372
|
+
rps=30.0, total_requests=300, failed_requests=30, p95_us=35000.0
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
result = compute_comparison(current, baseline)
|
|
376
|
+
assert result["rps_delta"] > 0 # RPS improved
|
|
377
|
+
assert result["latency_p95_delta"] < 0 # latency improved
|
|
378
|
+
assert result["error_rate_delta"] < 0 # errors improved
|
|
379
|
+
|
|
380
|
+
def test_compute_comparison_zero_baseline_rps(self):
|
|
381
|
+
from strobengine.reporting.baseline import compute_comparison
|
|
382
|
+
|
|
383
|
+
current = self._make_artifact(
|
|
384
|
+
rps=10.0, total_requests=100, failed_requests=0, p95_us=5000.0
|
|
385
|
+
)
|
|
386
|
+
baseline = self._make_artifact(
|
|
387
|
+
rps=0.0, total_requests=100, failed_requests=0, p95_us=5000.0
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
result = compute_comparison(current, baseline)
|
|
391
|
+
assert result["rps_delta"] == 100.0 # base=0, curr>0 -> 100%
|
|
392
|
+
|
|
393
|
+
def test_compute_comparison_zero_baseline_p95(self):
|
|
394
|
+
from strobengine.reporting.baseline import compute_comparison
|
|
395
|
+
|
|
396
|
+
current = self._make_artifact(
|
|
397
|
+
rps=10.0, total_requests=100, failed_requests=0, p95_us=10000.0
|
|
398
|
+
)
|
|
399
|
+
baseline = self._make_artifact(
|
|
400
|
+
rps=10.0, total_requests=100, failed_requests=0, p95_us=0.0
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
result = compute_comparison(current, baseline)
|
|
404
|
+
assert result["latency_p95_delta"] == 100.0 # base=0, curr>0 -> 100%
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class TestHTMLReport:
|
|
408
|
+
"""Tests for render_html_report and save_html_report."""
|
|
409
|
+
|
|
410
|
+
def test_render_html_contains_charts(self):
|
|
411
|
+
from strobengine.reporting.html_report import render_html_report
|
|
412
|
+
|
|
413
|
+
html = render_html_report(_make_summary(), _make_config())
|
|
414
|
+
assert "<canvas" in html
|
|
415
|
+
assert "Chart" in html
|
|
416
|
+
|
|
417
|
+
def test_render_html_contains_metadata(self):
|
|
418
|
+
from strobengine.reporting.html_report import render_html_report
|
|
419
|
+
|
|
420
|
+
html = render_html_report(_make_summary(), _make_config())
|
|
421
|
+
assert "http://localhost:8080" in html
|
|
422
|
+
assert "10.0s" in html
|
|
423
|
+
|
|
424
|
+
def test_render_html_with_comparison(self):
|
|
425
|
+
from strobengine.reporting.html_report import render_html_report
|
|
426
|
+
|
|
427
|
+
comparison = {
|
|
428
|
+
"baseline_timestamp": "2026-01-01T00:00:00Z",
|
|
429
|
+
"rps_delta": 10.5,
|
|
430
|
+
"latency_p95_delta": -5.2,
|
|
431
|
+
"error_rate_delta": -1.0,
|
|
432
|
+
"baseline_rps": 30.0,
|
|
433
|
+
"baseline_p95_ms": 35.0,
|
|
434
|
+
"baseline_error_rate": 5.0,
|
|
435
|
+
}
|
|
436
|
+
html = render_html_report(
|
|
437
|
+
_make_summary(), _make_config(), comparison=comparison
|
|
438
|
+
)
|
|
439
|
+
assert "Historical Comparison" in html
|
|
440
|
+
|
|
441
|
+
def test_render_html_without_comparison(self):
|
|
442
|
+
from strobengine.reporting.html_report import render_html_report
|
|
443
|
+
|
|
444
|
+
html = render_html_report(_make_summary(), _make_config(), comparison=None)
|
|
445
|
+
assert "Historical Comparison" not in html
|
|
446
|
+
|
|
447
|
+
def test_render_html_status_grouping(self):
|
|
448
|
+
from strobengine.reporting.html_report import render_html_report
|
|
449
|
+
|
|
450
|
+
summary = _make_summary(total_errors=10, status_codes={200: 90, 404: 5, 500: 5})
|
|
451
|
+
html = render_html_report(summary, _make_config())
|
|
452
|
+
assert "2xx" in html
|
|
453
|
+
assert "4xx" in html
|
|
454
|
+
assert "5xx" in html
|
|
455
|
+
|
|
456
|
+
def test_save_html_report_file_output(self, tmp_path):
|
|
457
|
+
from strobengine.reporting.html_report import save_html_report
|
|
458
|
+
|
|
459
|
+
filepath = str(tmp_path / "report.html")
|
|
460
|
+
result = save_html_report(_make_summary(), _make_config(), filepath)
|
|
461
|
+
assert result == filepath
|
|
462
|
+
assert (tmp_path / "report.html").exists()
|
|
463
|
+
content = (tmp_path / "report.html").read_text()
|
|
464
|
+
assert "<html" in content
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class TestCLIHelpers:
|
|
468
|
+
"""Tests for cli.py helper functions."""
|
|
469
|
+
|
|
470
|
+
def test_parse_headers_valid(self):
|
|
471
|
+
from strobengine.cli import _parse_headers
|
|
472
|
+
|
|
473
|
+
result = _parse_headers(["X-Custom: value"])
|
|
474
|
+
assert result == [("X-Custom", "value")]
|
|
475
|
+
|
|
476
|
+
def test_parse_headers_invalid(self):
|
|
477
|
+
from strobengine.cli import _parse_headers
|
|
478
|
+
|
|
479
|
+
with pytest.raises(typer.BadParameter):
|
|
480
|
+
_parse_headers(["NoColonHere"])
|
|
481
|
+
|
|
482
|
+
def test_validate_method_uppercase(self):
|
|
483
|
+
from strobengine.cli import _validate_method
|
|
484
|
+
|
|
485
|
+
assert _validate_method("get") == "GET"
|
|
486
|
+
assert _validate_method("post") == "POST"
|
|
487
|
+
|
|
488
|
+
def test_validate_method_invalid(self):
|
|
489
|
+
from strobengine.cli import _validate_method
|
|
490
|
+
|
|
491
|
+
with pytest.raises(typer.BadParameter):
|
|
492
|
+
_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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|