strest 0.1.10

Blazing-fast async HTTP load tester in Rust - lock-free design, real-time stats, distributed runs, and optional chart exports for high-load API testing.
Documentation
use super::bounds::{BoundDefault, parse_bound, resolve_bound};
use super::records::{read_csv_records, read_json_records, read_jsonl_records};
use super::state::{ReplayWindow, SnapshotMarkers};
use super::ui::build_ui_data_with_config;
use super::window_slice;
use crate::error::{AppError, AppResult};
use crate::metrics::MetricRecord;
use std::time::Duration;
use tempfile::tempdir;

fn run_async_test<F>(future: F) -> AppResult<()>
where
    F: std::future::Future<Output = AppResult<()>>,
{
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|err| AppError::metrics(format!("Failed to build runtime: {}", err)))?;
    runtime.block_on(future)
}

#[test]
fn parse_bound_accepts_min_max_and_duration() -> AppResult<()> {
    match parse_bound("min")? {
        super::bounds::ReplayBound::Min => {}
        super::bounds::ReplayBound::Max | super::bounds::ReplayBound::Duration(_) => {
            return Err(AppError::validation("Expected min bound"));
        }
    }
    match parse_bound("max")? {
        super::bounds::ReplayBound::Max => {}
        super::bounds::ReplayBound::Min | super::bounds::ReplayBound::Duration(_) => {
            return Err(AppError::validation("Expected max bound"));
        }
    }
    match parse_bound("10s")? {
        super::bounds::ReplayBound::Duration(duration) => {
            if duration != Duration::from_secs(10) {
                return Err(AppError::validation("Unexpected duration value"));
            }
        }
        super::bounds::ReplayBound::Min | super::bounds::ReplayBound::Max => {
            return Err(AppError::validation("Expected duration bound"));
        }
    }
    Ok(())
}

#[test]
fn resolve_bound_clamps_to_range() -> AppResult<()> {
    let min = 100;
    let max = 200;
    let resolved_max = resolve_bound(Some("1s"), min, max, BoundDefault::Min)?;
    if resolved_max != max {
        return Err(AppError::validation(format!(
            "Expected clamp to {}, got {}",
            max, resolved_max
        )));
    }
    let resolved_min = resolve_bound(Some("min"), min, max, BoundDefault::Max)?;
    if resolved_min != min {
        return Err(AppError::validation(format!(
            "Expected min {} got {}",
            min, resolved_min
        )));
    }
    Ok(())
}

#[test]
fn window_slice_handles_bounds() -> AppResult<()> {
    let records = vec![
        MetricRecord {
            elapsed_ms: 0,
            latency_ms: 10,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 0,
            in_flight_ops: 0,
        },
        MetricRecord {
            elapsed_ms: 1000,
            latency_ms: 20,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 0,
            in_flight_ops: 0,
        },
    ];
    let first_slice = window_slice(&records, 0, 500);
    if first_slice.len() != 1 {
        return Err(AppError::validation(format!(
            "Expected 1 record, got {}",
            first_slice.len()
        )));
    }
    let empty_slice = window_slice(&records, 2000, 3000);
    if !empty_slice.is_empty() {
        return Err(AppError::validation(
            "Expected empty slice for out-of-range window",
        ));
    }
    Ok(())
}

#[test]
fn read_csv_records_parses_header_and_values() -> AppResult<()> {
    run_async_test(async {
        let dir = tempdir().map_err(|err| AppError::metrics(format!("tempdir failed: {}", err)))?;
        let path = dir.path().join("metrics.csv");
        tokio::fs::write(
            &path,
            "elapsed_ms,latency_ms,status_code,timed_out,transport_error\n1,10,200,0,1\n",
        )
        .await
        .map_err(|err| AppError::metrics(format!("write failed: {}", err)))?;

        let records = read_csv_records(&path).await?;
        if records.len() != 1 {
            return Err(AppError::validation(format!(
                "Expected 1 record, got {}",
                records.len()
            )));
        }
        let record = records
            .first()
            .ok_or_else(|| AppError::validation("Missing parsed record"))?;
        if record.elapsed_ms != 1 || record.latency_ms != 10 {
            return Err(AppError::validation("Unexpected record values"));
        }
        if !record.transport_error || record.timed_out {
            return Err(AppError::validation("Unexpected flags in record"));
        }
        Ok(())
    })
}

#[test]
fn read_csv_records_parses_flow_fields() -> AppResult<()> {
    run_async_test(async {
        let dir = tempdir().map_err(|err| AppError::metrics(format!("tempdir failed: {}", err)))?;
        let path = dir.path().join("metrics.csv");
        tokio::fs::write(
            &path,
            "elapsed_ms,latency_ms,status_code,timed_out,transport_error,response_bytes,in_flight_ops\n1,10,200,0,0,256,3\n",
        )
        .await
        .map_err(|err| AppError::metrics(format!("write failed: {}", err)))?;

        let records = read_csv_records(&path).await?;
        let record = records
            .first()
            .ok_or_else(|| AppError::validation("Missing parsed record"))?;
        if record.response_bytes != 256 || record.in_flight_ops != 3 {
            return Err(AppError::validation(format!(
                "Unexpected flow fields: response_bytes={}, in_flight_ops={}",
                record.response_bytes, record.in_flight_ops
            )));
        }
        Ok(())
    })
}

#[test]
fn read_json_records_parses_payload() -> AppResult<()> {
    run_async_test(async {
        let dir = tempdir().map_err(|err| AppError::metrics(format!("tempdir failed: {}", err)))?;
        let path = dir.path().join("metrics.json");
        let payload = r#"{
                "summary": { "duration_ms": 10 },
                "records": [
                    { "elapsed_ms": 5, "latency_ms": 20, "status_code": 200, "timed_out": false, "transport_error": false }
                ]
            }"#;
        tokio::fs::write(&path, payload)
            .await
            .map_err(|err| AppError::metrics(format!("write failed: {}", err)))?;
        let records = read_json_records(&path).await?;
        if records.len() != 1 {
            return Err(AppError::validation(format!(
                "Expected 1 record, got {}",
                records.len()
            )));
        }
        let record = records
            .first()
            .ok_or_else(|| AppError::validation("Missing parsed record"))?;
        if record.elapsed_ms != 5 || record.latency_ms != 20 {
            return Err(AppError::validation("Unexpected record values"));
        }
        Ok(())
    })
}

#[test]
fn read_jsonl_records_parses_payload() -> AppResult<()> {
    run_async_test(async {
        let dir = tempdir().map_err(|err| AppError::metrics(format!("tempdir failed: {}", err)))?;
        let path = dir.path().join("metrics.jsonl");
        let payload = r#"{"type":"summary","duration_ms":10}
{"type":"record","elapsed_ms":5,"latency_ms":20,"status_code":200,"timed_out":false,"transport_error":false}
"#;
        tokio::fs::write(&path, payload)
            .await
            .map_err(|err| AppError::metrics(format!("write failed: {}", err)))?;
        let records = read_jsonl_records(&path).await?;
        if records.len() != 1 {
            return Err(AppError::validation(format!(
                "Expected 1 record, got {}",
                records.len()
            )));
        }
        let record = records
            .first()
            .ok_or_else(|| AppError::validation("Missing parsed record"))?;
        if record.elapsed_ms != 5 || record.latency_ms != 20 {
            return Err(AppError::validation("Unexpected record values"));
        }
        Ok(())
    })
}

#[test]
fn replay_ui_excludes_trailing_partial_bucket_from_rate_series() -> AppResult<()> {
    let records = vec![
        MetricRecord {
            elapsed_ms: 1000,
            latency_ms: 5,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 100,
            in_flight_ops: 1,
        },
        MetricRecord {
            elapsed_ms: 1900,
            latency_ms: 5,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 100,
            in_flight_ops: 1,
        },
        MetricRecord {
            elapsed_ms: 2000,
            latency_ms: 5,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 100,
            in_flight_ops: 1,
        },
    ];

    let state = ReplayWindow {
        start_ms: 0,
        cursor_ms: 2300,
        end_ms: 2300,
        playing: false,
    };
    let data = build_ui_data_with_config(
        &records,
        200,
        10_000,
        true,
        &state,
        &SnapshotMarkers::default(),
        None,
    )?;
    let last_rps_bucket = data
        .rps_series
        .last()
        .map(|(bucket, _)| *bucket)
        .unwrap_or(0);
    let last_data_bucket = data
        .data_usage
        .as_ref()
        .and_then(|usage| usage.series.last().map(|(bucket, _)| *bucket))
        .unwrap_or(0);
    if last_rps_bucket >= 2000 || last_data_bucket >= 2000 {
        return Err(AppError::validation(format!(
            "Expected trailing partial bucket to be excluded, got rps={}, data={}",
            last_rps_bucket, last_data_bucket
        )));
    }
    Ok(())
}