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.
Files changed (97) hide show
  1. {strobengine-0.5.1 → strobengine-0.5.2}/CHANGELOG.md +22 -0
  2. {strobengine-0.5.1 → strobengine-0.5.2}/Cargo.lock +1 -1
  3. {strobengine-0.5.1 → strobengine-0.5.2}/Cargo.toml +1 -1
  4. {strobengine-0.5.1 → strobengine-0.5.2}/PKG-INFO +1 -1
  5. {strobengine-0.5.1 → strobengine-0.5.2}/pyproject.toml +1 -1
  6. {strobengine-0.5.1 → strobengine-0.5.2}/src/lib.rs +15 -13
  7. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/websocket.rs +3 -2
  8. {strobengine-0.5.1 → strobengine-0.5.2}/src/report/schema.rs +1 -1
  9. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporter.py +7 -1
  10. strobengine-0.5.2/src/strobengine/reporting/__init__.py +8 -0
  11. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/baseline.py +24 -2
  12. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/html_report.py +6 -4
  13. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/junit_report.py +6 -4
  14. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/markdown_report.py +5 -4
  15. {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_reporting.py +18 -91
  16. {strobengine-0.5.1 → strobengine-0.5.2}/uv.lock +1 -1
  17. strobengine-0.5.1/tests/e2e/__init__.py +0 -0
  18. {strobengine-0.5.1 → strobengine-0.5.2}/.dockerignore +0 -0
  19. {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/ci.yml +0 -0
  20. {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/docker-release.yml +0 -0
  21. {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/publish-test.yml +0 -0
  22. {strobengine-0.5.1 → strobengine-0.5.2}/.github/workflows/publish.yml +0 -0
  23. {strobengine-0.5.1 → strobengine-0.5.2}/.gitignore +0 -0
  24. {strobengine-0.5.1 → strobengine-0.5.2}/.pre-commit-config.yaml +0 -0
  25. {strobengine-0.5.1 → strobengine-0.5.2}/.python-version +0 -0
  26. {strobengine-0.5.1 → strobengine-0.5.2}/Dockerfile +0 -0
  27. {strobengine-0.5.1 → strobengine-0.5.2}/LICENSE +0 -0
  28. {strobengine-0.5.1 → strobengine-0.5.2}/Makefile +0 -0
  29. {strobengine-0.5.1 → strobengine-0.5.2}/README.md +0 -0
  30. {strobengine-0.5.1 → strobengine-0.5.2}/cliff.toml +0 -0
  31. {strobengine-0.5.1 → strobengine-0.5.2}/docs/benchmarks.md +0 -0
  32. {strobengine-0.5.1 → strobengine-0.5.2}/docs/cli.md +0 -0
  33. {strobengine-0.5.1 → strobengine-0.5.2}/docs/dependencies.md +0 -0
  34. {strobengine-0.5.1 → strobengine-0.5.2}/docs/docker.md +0 -0
  35. {strobengine-0.5.1 → strobengine-0.5.2}/docs/grpc.md +0 -0
  36. {strobengine-0.5.1 → strobengine-0.5.2}/docs/http3.md +0 -0
  37. {strobengine-0.5.1 → strobengine-0.5.2}/docs/http_methods.md +0 -0
  38. {strobengine-0.5.1 → strobengine-0.5.2}/docs/infrastructure.md +0 -0
  39. {strobengine-0.5.1 → strobengine-0.5.2}/docs/install.md +0 -0
  40. {strobengine-0.5.1 → strobengine-0.5.2}/docs/quickstart.md +0 -0
  41. {strobengine-0.5.1 → strobengine-0.5.2}/docs/release.md +0 -0
  42. {strobengine-0.5.1 → strobengine-0.5.2}/docs/reports.md +0 -0
  43. {strobengine-0.5.1 → strobengine-0.5.2}/docs/roadmap.md +0 -0
  44. {strobengine-0.5.1 → strobengine-0.5.2}/docs/sse.md +0 -0
  45. {strobengine-0.5.1 → strobengine-0.5.2}/docs/table_of_contents.md +0 -0
  46. {strobengine-0.5.1 → strobengine-0.5.2}/docs/testing.md +0 -0
  47. {strobengine-0.5.1 → strobengine-0.5.2}/docs/websockets.md +0 -0
  48. {strobengine-0.5.1 → strobengine-0.5.2}/examples/grpc.py +0 -0
  49. {strobengine-0.5.1 → strobengine-0.5.2}/examples/http3.py +0 -0
  50. {strobengine-0.5.1 → strobengine-0.5.2}/examples/http_methods.py +0 -0
  51. {strobengine-0.5.1 → strobengine-0.5.2}/examples/load_test.py +0 -0
  52. {strobengine-0.5.1 → strobengine-0.5.2}/examples/spike_test.py +0 -0
  53. {strobengine-0.5.1 → strobengine-0.5.2}/examples/sse.py +0 -0
  54. {strobengine-0.5.1 → strobengine-0.5.2}/examples/stress_test.py +0 -0
  55. {strobengine-0.5.1 → strobengine-0.5.2}/examples/websocket.py +0 -0
  56. {strobengine-0.5.1 → strobengine-0.5.2}/rust-toolchain.toml +0 -0
  57. {strobengine-0.5.1 → strobengine-0.5.2}/scripts/release.py +0 -0
  58. {strobengine-0.5.1 → strobengine-0.5.2}/src/chaos.rs +0 -0
  59. {strobengine-0.5.1 → strobengine-0.5.2}/src/config.rs +0 -0
  60. {strobengine-0.5.1 → strobengine-0.5.2}/src/logging.rs +0 -0
  61. {strobengine-0.5.1 → strobengine-0.5.2}/src/metrics.rs +0 -0
  62. {strobengine-0.5.1 → strobengine-0.5.2}/src/progress.rs +0 -0
  63. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc.rs +0 -0
  64. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc_parser.rs +0 -0
  65. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/grpc_reflection.rs +0 -0
  66. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/http.rs +0 -0
  67. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/http3.rs +0 -0
  68. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/mod.rs +0 -0
  69. {strobengine-0.5.1 → strobengine-0.5.2}/src/protocols/sse.rs +0 -0
  70. {strobengine-0.5.1 → strobengine-0.5.2}/src/report/mod.rs +0 -0
  71. {strobengine-0.5.1 → strobengine-0.5.2}/src/report/writer.rs +0 -0
  72. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/__init__.py +0 -0
  73. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/_strobengine.pyi +0 -0
  74. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/cli.py +0 -0
  75. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/constants.py +0 -0
  76. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/engine.py +0 -0
  77. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/py.typed +0 -0
  78. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/assets/chart.min.js +0 -0
  79. {strobengine-0.5.1 → strobengine-0.5.2}/src/strobengine/reporting/csv_report.py +0 -0
  80. {strobengine-0.5.1/src/strobengine/reporting → strobengine-0.5.2/tests}/__init__.py +0 -0
  81. {strobengine-0.5.1 → strobengine-0.5.2}/tests/conftest.py +0 -0
  82. {strobengine-0.5.1/tests → strobengine-0.5.2/tests/e2e}/__init__.py +0 -0
  83. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/conftest.py +0 -0
  84. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/mock_server.py +0 -0
  85. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_cli_interface.py +0 -0
  86. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_grpc_e2e.py +0 -0
  87. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_http3_e2e.py +0 -0
  88. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_http_scenarios.py +0 -0
  89. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_performance_sanity.py +0 -0
  90. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_persistence_e2e.py +0 -0
  91. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_sse_e2e.py +0 -0
  92. {strobengine-0.5.1 → strobengine-0.5.2}/tests/e2e/test_websocket_e2e.py +0 -0
  93. {strobengine-0.5.1 → strobengine-0.5.2}/tests/fixtures/service.proto +0 -0
  94. {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_cli.py +0 -0
  95. {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_engine.py +0 -0
  96. {strobengine-0.5.1 → strobengine-0.5.2}/tests/test_logging.py +0 -0
  97. {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
@@ -1785,7 +1785,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1785
1785
 
1786
1786
  [[package]]
1787
1787
  name = "strobengine"
1788
- version = "0.5.1"
1788
+ version = "0.5.2"
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.1"
3
+ version = "0.5.2"
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.1
3
+ Version: 0.5.2
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.1"
3
+ version = "0.5.2"
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"
@@ -72,11 +72,11 @@ fn parse_method(method_str: &str) -> PyResult<Method> {
72
72
  })
73
73
  }
74
74
 
75
- fn parse_body(body: Option<String>) -> Option<bytes::Bytes> {
76
- body.map(bytes::Bytes::from)
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<Vec<(String, String)>>) -> Option<bytes::Bytes> {
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<Vec<(String, String)>>) -> PyResult<HeaderMap> {
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(&v).map_err(|e| {
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.clone());
483
- let form = parse_form(config.form.clone());
484
- let header_map = parse_headers(config.headers.clone())?;
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 _ = report::writer::save_report_json(
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.clone())?;
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.clone())))
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: payload_bytes.len() as u64,
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: (rps * 100.0).round() / 100.0,
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": "0.5.0",
243
+ "version": pkg_version,
238
244
  }
239
245
 
240
246
 
@@ -0,0 +1,8 @@
1
+ """Shared utilities for reporting modules."""
2
+
3
+
4
+ def us_to_ms(us: float | None) -> float:
5
+ """Convert microseconds to milliseconds, rounded to 2 decimal places."""
6
+ if us is None:
7
+ return 0.0
8
+ return round(us / 1000, 2)
@@ -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": round(base_lp["p95_us"] / 1000, 2),
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": round(curr_lp["p95_us"] / 1000, 2),
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": round((lp.get("p50_us") or 0) / 1000, 2),
198
- "p90": round((lp.get("p90_us") or 0) / 1000, 2),
199
- "p95": round((lp.get("p95_us") or 0) / 1000, 2),
200
- "p99": round((lp.get("p99_us") or 0) / 1000, 2),
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
- p95_ms = lp["p95_us"] / 1000
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'] / 1000:.2f}ms, "
52
- f"P95: {p95_ms:.2f}ms, "
53
- f"P99: {lp['p99_us'] / 1000:.2f}ms, "
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'] / 1000:.2f} ms |",
47
- f"| P90 Latency | {lp['p90_us'] / 1000:.2f} ms |",
48
- f"| P95 Latency | {lp['p95_us'] / 1000:.2f} ms |",
49
- f"| P99 Latency | {lp['p99_us'] / 1000:.2f} ms |",
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
- from strobengine.reporting.baseline import load_baseline_artifact
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(__import__("json").dumps(artifact))
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
- from strobengine.reporting.baseline import load_baseline_artifact
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(__import__("json").dumps(artifact))
265
+ report_file.write_text(json.dumps(artifact))
307
266
 
308
267
  latest = tmp_path / "latest.json"
309
- latest.write_text(__import__("json").dumps({"latest_report": report_file.name}))
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
- from strobengine.reporting.baseline import load_baseline_artifact
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")
@@ -732,7 +732,7 @@ wheels = [
732
732
 
733
733
  [[package]]
734
734
  name = "strobengine"
735
- version = "0.5.1"
735
+ version = "0.5.2"
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