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.
Files changed (96) hide show
  1. {strobengine-0.5.0 → strobengine-0.5.1}/CHANGELOG.md +18 -0
  2. {strobengine-0.5.0 → strobengine-0.5.1}/Cargo.lock +1 -1
  3. {strobengine-0.5.0 → strobengine-0.5.1}/Cargo.toml +1 -1
  4. {strobengine-0.5.0 → strobengine-0.5.1}/PKG-INFO +1 -1
  5. {strobengine-0.5.0 → strobengine-0.5.1}/pyproject.toml +1 -1
  6. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/http.rs +1 -1
  7. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/websocket.rs +19 -7
  8. {strobengine-0.5.0 → strobengine-0.5.1}/src/report/writer.rs +31 -0
  9. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/baseline.py +3 -3
  10. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/markdown_report.py +0 -57
  11. {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_reporting.py +240 -0
  12. {strobengine-0.5.0 → strobengine-0.5.1}/uv.lock +1 -1
  13. {strobengine-0.5.0 → strobengine-0.5.1}/.dockerignore +0 -0
  14. {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/ci.yml +0 -0
  15. {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/docker-release.yml +0 -0
  16. {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/publish-test.yml +0 -0
  17. {strobengine-0.5.0 → strobengine-0.5.1}/.github/workflows/publish.yml +0 -0
  18. {strobengine-0.5.0 → strobengine-0.5.1}/.gitignore +0 -0
  19. {strobengine-0.5.0 → strobengine-0.5.1}/.pre-commit-config.yaml +0 -0
  20. {strobengine-0.5.0 → strobengine-0.5.1}/.python-version +0 -0
  21. {strobengine-0.5.0 → strobengine-0.5.1}/Dockerfile +0 -0
  22. {strobengine-0.5.0 → strobengine-0.5.1}/LICENSE +0 -0
  23. {strobengine-0.5.0 → strobengine-0.5.1}/Makefile +0 -0
  24. {strobengine-0.5.0 → strobengine-0.5.1}/README.md +0 -0
  25. {strobengine-0.5.0 → strobengine-0.5.1}/cliff.toml +0 -0
  26. {strobengine-0.5.0 → strobengine-0.5.1}/docs/benchmarks.md +0 -0
  27. {strobengine-0.5.0 → strobengine-0.5.1}/docs/cli.md +0 -0
  28. {strobengine-0.5.0 → strobengine-0.5.1}/docs/dependencies.md +0 -0
  29. {strobengine-0.5.0 → strobengine-0.5.1}/docs/docker.md +0 -0
  30. {strobengine-0.5.0 → strobengine-0.5.1}/docs/grpc.md +0 -0
  31. {strobengine-0.5.0 → strobengine-0.5.1}/docs/http3.md +0 -0
  32. {strobengine-0.5.0 → strobengine-0.5.1}/docs/http_methods.md +0 -0
  33. {strobengine-0.5.0 → strobengine-0.5.1}/docs/infrastructure.md +0 -0
  34. {strobengine-0.5.0 → strobengine-0.5.1}/docs/install.md +0 -0
  35. {strobengine-0.5.0 → strobengine-0.5.1}/docs/quickstart.md +0 -0
  36. {strobengine-0.5.0 → strobengine-0.5.1}/docs/release.md +0 -0
  37. {strobengine-0.5.0 → strobengine-0.5.1}/docs/reports.md +0 -0
  38. {strobengine-0.5.0 → strobengine-0.5.1}/docs/roadmap.md +0 -0
  39. {strobengine-0.5.0 → strobengine-0.5.1}/docs/sse.md +0 -0
  40. {strobengine-0.5.0 → strobengine-0.5.1}/docs/table_of_contents.md +0 -0
  41. {strobengine-0.5.0 → strobengine-0.5.1}/docs/testing.md +0 -0
  42. {strobengine-0.5.0 → strobengine-0.5.1}/docs/websockets.md +0 -0
  43. {strobengine-0.5.0 → strobengine-0.5.1}/examples/grpc.py +0 -0
  44. {strobengine-0.5.0 → strobengine-0.5.1}/examples/http3.py +0 -0
  45. {strobengine-0.5.0 → strobengine-0.5.1}/examples/http_methods.py +0 -0
  46. {strobengine-0.5.0 → strobengine-0.5.1}/examples/load_test.py +0 -0
  47. {strobengine-0.5.0 → strobengine-0.5.1}/examples/spike_test.py +0 -0
  48. {strobengine-0.5.0 → strobengine-0.5.1}/examples/sse.py +0 -0
  49. {strobengine-0.5.0 → strobengine-0.5.1}/examples/stress_test.py +0 -0
  50. {strobengine-0.5.0 → strobengine-0.5.1}/examples/websocket.py +0 -0
  51. {strobengine-0.5.0 → strobengine-0.5.1}/rust-toolchain.toml +0 -0
  52. {strobengine-0.5.0 → strobengine-0.5.1}/scripts/release.py +0 -0
  53. {strobengine-0.5.0 → strobengine-0.5.1}/src/chaos.rs +0 -0
  54. {strobengine-0.5.0 → strobengine-0.5.1}/src/config.rs +0 -0
  55. {strobengine-0.5.0 → strobengine-0.5.1}/src/lib.rs +0 -0
  56. {strobengine-0.5.0 → strobengine-0.5.1}/src/logging.rs +0 -0
  57. {strobengine-0.5.0 → strobengine-0.5.1}/src/metrics.rs +0 -0
  58. {strobengine-0.5.0 → strobengine-0.5.1}/src/progress.rs +0 -0
  59. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc.rs +0 -0
  60. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc_parser.rs +0 -0
  61. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/grpc_reflection.rs +0 -0
  62. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/http3.rs +0 -0
  63. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/mod.rs +0 -0
  64. {strobengine-0.5.0 → strobengine-0.5.1}/src/protocols/sse.rs +0 -0
  65. {strobengine-0.5.0 → strobengine-0.5.1}/src/report/mod.rs +0 -0
  66. {strobengine-0.5.0 → strobengine-0.5.1}/src/report/schema.rs +0 -0
  67. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/__init__.py +0 -0
  68. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/_strobengine.pyi +0 -0
  69. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/cli.py +0 -0
  70. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/constants.py +0 -0
  71. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/engine.py +0 -0
  72. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/py.typed +0 -0
  73. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporter.py +0 -0
  74. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/__init__.py +0 -0
  75. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/assets/chart.min.js +0 -0
  76. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/csv_report.py +0 -0
  77. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/html_report.py +0 -0
  78. {strobengine-0.5.0 → strobengine-0.5.1}/src/strobengine/reporting/junit_report.py +0 -0
  79. {strobengine-0.5.0 → strobengine-0.5.1}/tests/__init__.py +0 -0
  80. {strobengine-0.5.0 → strobengine-0.5.1}/tests/conftest.py +0 -0
  81. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/__init__.py +0 -0
  82. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/conftest.py +0 -0
  83. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/mock_server.py +0 -0
  84. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_cli_interface.py +0 -0
  85. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_grpc_e2e.py +0 -0
  86. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_http3_e2e.py +0 -0
  87. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_http_scenarios.py +0 -0
  88. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_performance_sanity.py +0 -0
  89. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_persistence_e2e.py +0 -0
  90. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_sse_e2e.py +0 -0
  91. {strobengine-0.5.0 → strobengine-0.5.1}/tests/e2e/test_websocket_e2e.py +0 -0
  92. {strobengine-0.5.0 → strobengine-0.5.1}/tests/fixtures/service.proto +0 -0
  93. {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_cli.py +0 -0
  94. {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_engine.py +0 -0
  95. {strobengine-0.5.0 → strobengine-0.5.1}/tests/test_logging.py +0 -0
  96. {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
 
@@ -1785,7 +1785,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1785
1785
 
1786
1786
  [[package]]
1787
1787
  name = "strobengine"
1788
- version = "0.5.0"
1788
+ version = "0.5.1"
1789
1789
  dependencies = [
1790
1790
  "async-trait",
1791
1791
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "strobengine"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strobengine
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Requires-Dist: rich>=13.0.0
5
5
  Requires-Dist: typer>=0.27.0
6
6
  Requires-Dist: jinja2>=3.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "strobengine"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "A load testing framework with a Python UI and a bare-metal Rust engine core."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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
- .post(target_url)
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
- if let (Ok(name), Ok(val)) = (
100
+ match (
101
101
  key.as_str().parse::<http::header::HeaderName>(),
102
102
  value.as_str().parse(),
103
103
  ) {
104
- request.headers_mut().insert(name, val);
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(5);
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
- if let (Ok(name), Ok(val)) =
287
- (key.as_str().parse::<HeaderName>(), value.as_str().parse())
288
- {
289
- request.headers_mut().insert(name, val);
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")
@@ -732,7 +732,7 @@ wheels = [
732
732
 
733
733
  [[package]]
734
734
  name = "strobengine"
735
- version = "0.5.0"
735
+ version = "0.5.1"
736
736
  source = { editable = "." }
737
737
  dependencies = [
738
738
  { name = "jinja2" },
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