|
| 1 | +defmodule LivebookExtensions.K6Runner do |
| 2 | + @moduledoc """ |
| 3 | + Interactive k6 load test runner using Kino.Control.form. |
| 4 | +
|
| 5 | + This module provides a simple UI to: |
| 6 | + - Select which phase to test |
| 7 | + - Choose test type (smoke, load, stress) |
| 8 | + - Configure virtual users and duration |
| 9 | + - Run k6 tests and display results inline |
| 10 | +
|
| 11 | + ## Usage |
| 12 | +
|
| 13 | + In a Livebook cell: |
| 14 | +
|
| 15 | + LivebookExtensions.K6Runner.render() |
| 16 | + """ |
| 17 | + |
| 18 | + def render(opts \\ []) do |
| 19 | + phases = Enum.map(1..15, &"Phase #{String.pad_leading(to_string(&1), 2, "0")}") |
| 20 | + default_phase = opts[:phase] || List.first(phases) |
| 21 | + |
| 22 | + form = |
| 23 | + Kino.Control.form( |
| 24 | + [ |
| 25 | + phase: {:select, "Select phase to test", phases |> Enum.map(&{&1, &1})}, |
| 26 | + test_type: |
| 27 | + {:select, "Test type", |
| 28 | + [ |
| 29 | + {"Smoke test (quick validation)", "smoke"}, |
| 30 | + {"Load test (sustained load)", "load"}, |
| 31 | + {"Stress test (breaking point)", "stress"} |
| 32 | + ], default: "load"}, |
| 33 | + vus: {:number, "Virtual users", default: 50}, |
| 34 | + duration: {:text, "Duration (e.g., 30s, 1m)", default: "30s"} |
| 35 | + ], |
| 36 | + submit: "Run k6 Test" |
| 37 | + ) |
| 38 | + |
| 39 | + Kino.render(form) |
| 40 | + |
| 41 | + # Listen for form submission and run k6 |
| 42 | + Kino.listen(form, fn %{data: data} -> |
| 43 | + run_k6(data) |
| 44 | + end) |
| 45 | + |
| 46 | + form |
| 47 | + end |
| 48 | + |
| 49 | + defp run_k6(data) do |
| 50 | + # Extract phase number from "Phase 01" format |
| 51 | + phase_num = data.phase |> String.split() |> List.last() |
| 52 | + script_path = "tools/k6/phase-#{phase_num}-gate.js" |
| 53 | + |
| 54 | + result = |
| 55 | + if File.exists?(script_path) do |
| 56 | + {output, exit_code} = |
| 57 | + System.cmd( |
| 58 | + "k6", |
| 59 | + [ |
| 60 | + "run", |
| 61 | + "--vus", |
| 62 | + to_string(data.vus), |
| 63 | + "--duration", |
| 64 | + data.duration, |
| 65 | + script_path |
| 66 | + ], |
| 67 | + stderr_to_stdout: true |
| 68 | + ) |
| 69 | + |
| 70 | + metrics = parse_k6_output(output) |
| 71 | + |
| 72 | + """ |
| 73 | + ## k6 Load Test Results: #{data.phase} |
| 74 | +
|
| 75 | + **Configuration:** |
| 76 | + - Test type: #{data.test_type} |
| 77 | + - Virtual users: #{data.vus} |
| 78 | + - Duration: #{data.duration} |
| 79 | +
|
| 80 | + **Metrics:** |
| 81 | + - Requests/sec: #{metrics.rps} |
| 82 | + - p95 Latency: #{metrics.p95} |
| 83 | + - Error Rate: #{metrics.error_rate} |
| 84 | +
|
| 85 | + #{if metrics.error_rate_numeric > 1.0, do: "⚠️ **Error rate above 1%**", else: "✅ **All systems nominal**"} |
| 86 | +
|
| 87 | + ### Full Output |
| 88 | +
|
| 89 | + ``` |
| 90 | + #{output} |
| 91 | + ``` |
| 92 | + """ |
| 93 | + else |
| 94 | + """ |
| 95 | + ❌ **k6 script not found:** `#{script_path}` |
| 96 | +
|
| 97 | + Make sure the k6 test scripts exist in `tools/k6/`. |
| 98 | +
|
| 99 | + Available scripts: |
| 100 | + ``` |
| 101 | + #{case File.ls("tools/k6") do |
| 102 | + {:ok, files} -> Enum.join(files, "\n") |
| 103 | + {:error, _} -> "tools/k6 directory not found" |
| 104 | + end} |
| 105 | + ``` |
| 106 | + """ |
| 107 | + end |
| 108 | + |
| 109 | + Kino.Markdown.new(result) |> Kino.render() |
| 110 | + end |
| 111 | + |
| 112 | + defp parse_k6_output(output) do |
| 113 | + %{ |
| 114 | + rps: extract_metric(output, ~r/http_reqs.*?([\d.]+)\/s/, "N/A"), |
| 115 | + p95: extract_metric(output, ~r/http_req_duration.*?p\(95\)=([\d.]+)ms/, "N/A"), |
| 116 | + error_rate: extract_metric(output, ~r/http_req_failed.*?([\d.]+)%/, "0.0%"), |
| 117 | + error_rate_numeric: extract_numeric(output, ~r/http_req_failed.*?([\d.]+)%/, 0.0) |
| 118 | + } |
| 119 | + end |
| 120 | + |
| 121 | + defp extract_metric(output, regex, default) do |
| 122 | + case Regex.run(regex, output) do |
| 123 | + [_, value] -> value |
| 124 | + _ -> default |
| 125 | + end |
| 126 | + end |
| 127 | + |
| 128 | + defp extract_numeric(output, regex, default) do |
| 129 | + case Regex.run(regex, output) do |
| 130 | + [_, value] -> String.to_float(value) |
| 131 | + _ -> default |
| 132 | + end |
| 133 | + end |
| 134 | +end |
0 commit comments