Skip to content

Commit f9787ed

Browse files
authored
Merge pull request #36 from rlaope/feat/script-engine
feat: polyglot script engine for code-based load testing
2 parents 654b90e + 1dd617d commit f9787ed

22 files changed

Lines changed: 3721 additions & 2 deletions

README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,118 @@ make run-server
133133
# Endpoints: /health, /api/users, /api/stats, /api/echo
134134
```
135135

136+
## Script Engine (Code-Based Load Testing)
137+
138+
Write load tests in your preferred language — like k6, but polyglot with chaos patterns built-in.
139+
140+
```bash
141+
kar script test.star # Starlark (Python-like)
142+
kar script test.js # JavaScript
143+
kar script test.py # Python
144+
kar script test.rb # Ruby
145+
kar script test.star --vus 50 --duration 5m # Override VUs and duration
146+
kar script test.star --dashboard # Enable real-time web dashboard
147+
```
148+
149+
### Starlark (.star)
150+
151+
```python
152+
scenario(
153+
name = "api-load-test",
154+
pattern = chaos(preset = "aggressive", spike_factor = 3.0),
155+
vus = ramp([
156+
stage("30s", 10), # Ramp to 10 VUs over 30s
157+
stage("2m", 50), # Ramp to 50 VUs over 2m
158+
stage("30s", 0), # Ramp down
159+
]),
160+
thresholds = {
161+
"http_req_duration{p95}": "< 500ms",
162+
"http_req_failed": "< 0.05",
163+
},
164+
)
165+
166+
def setup():
167+
resp = http.post("http://api.example.com/auth", json={"user": "test"})
168+
return {"token": resp.json()["token"]}
169+
170+
def default(data):
171+
headers = {"Authorization": "Bearer " + data["token"]}
172+
resp = http.get("http://api.example.com/products", headers=headers)
173+
check(resp, {
174+
"status 200": lambda r: r.status == 200,
175+
"has items": lambda r: len(r.json()) > 0,
176+
})
177+
sleep(think_time("1s", "3s"))
178+
```
179+
180+
### JavaScript (.js)
181+
182+
```javascript
183+
scenario({
184+
name: "api-load-test",
185+
pattern: chaos({ preset: "moderate" }),
186+
thresholds: {
187+
"http_req_duration{p95}": "< 500ms",
188+
},
189+
});
190+
191+
function run(data) {
192+
var resp = http.get("http://api.example.com/health");
193+
check(resp, {
194+
"status 200": function(r) { return r.status === 200; },
195+
});
196+
}
197+
```
198+
199+
### Python (.py)
200+
201+
```python
202+
from kar98k import scenario, chaos, http, check, sleep, think_time
203+
204+
scenario(name="api-load-test", pattern=chaos(preset="moderate"))
205+
206+
def default(data):
207+
resp = http.get("http://api.example.com/health")
208+
check(resp, {
209+
"status 200": lambda r: r.status == 200,
210+
"has status": lambda r: "status" in r.json(),
211+
})
212+
sleep(think_time("1s", "3s"))
213+
```
214+
215+
### Ruby (.rb)
216+
217+
```ruby
218+
require_relative "../sdk/ruby/kar98k"
219+
220+
scenario name: "api-load-test", pattern: chaos(preset: "moderate")
221+
222+
def default(data)
223+
resp = Http.get("http://api.example.com/health")
224+
check resp,
225+
"status 200" => ->(r) { r.status == 200 }
226+
sleep_dur think_time("1s", "3s")
227+
end
228+
```
229+
230+
### Real-Time Dashboard
231+
232+
![kar98k Dashboard](./assets/dashboard.png)
233+
234+
Enable with `--dashboard`:
235+
236+
```bash
237+
kar script test.star --vus 20 --duration 5m --dashboard
238+
# Dashboard: http://localhost:8888
239+
```
240+
241+
Opens a web UI showing:
242+
- Live RPS and latency graphs
243+
- P95/P99 latency tracking
244+
- Error rate and status codes
245+
- Check pass/fail rates
246+
- VU count and iteration progress
247+
136248
## Commands
137249

138250
| Command | Description |

assets/dashboard.png

391 KB
Loading

examples/basic_test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// basic_test.js - Basic load test example (JavaScript)
2+
3+
scenario({
4+
name: "basic-health-check",
5+
pattern: chaos({ preset: "gentle" }),
6+
thresholds: {
7+
"http_req_duration{p95}": "< 500ms",
8+
"http_req_failed": "< 0.05",
9+
},
10+
});
11+
12+
// Main iteration function — called per VU
13+
function run(data) {
14+
var resp = http.get("http://localhost:8080/health");
15+
check(resp, {
16+
"status is 200": function(r) { return r.status === 200; },
17+
});
18+
}

examples/basic_test.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
"""k6-style load test written in Python."""
3+
4+
import sys, os
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "sdk", "python"))
6+
7+
from kar98k import scenario, chaos, http, check, sleep, think_time
8+
9+
# Configure scenario
10+
scenario(
11+
name="python-api-test",
12+
pattern=chaos(preset="moderate", spike_factor=2.5),
13+
thresholds={
14+
"http_req_duration{p95}": "< 500ms",
15+
"http_req_failed": "< 0.05",
16+
},
17+
)
18+
19+
# Setup — runs once
20+
def setup():
21+
return {"session": "py-session-abc"}
22+
23+
# Main iteration — runs per VU
24+
def default(data):
25+
# GET health
26+
resp = http.get("http://localhost:8080/health")
27+
check(resp, {
28+
"health status 200": lambda r: r.status == 200,
29+
"has status field": lambda r: "status" in r.json(),
30+
})
31+
32+
sleep(think_time("100ms", "500ms"))
33+
34+
# GET users
35+
resp = http.get("http://localhost:8080/api/users")
36+
check(resp, {
37+
"users status 200": lambda r: r.status == 200,
38+
})
39+
40+
# Teardown — runs once at end
41+
def teardown(data):
42+
pass

examples/basic_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env ruby
2+
# k6-style load test written in Ruby.
3+
4+
require_relative "../sdk/ruby/kar98k"
5+
6+
scenario name: "ruby-api-test",
7+
pattern: chaos(preset: "moderate", spike_factor: 2.0),
8+
thresholds: {
9+
"http_req_duration{p95}" => "< 500ms",
10+
"http_req_failed" => "< 0.05"
11+
}
12+
13+
def setup
14+
{ "session" => "rb-session-xyz" }
15+
end
16+
17+
def default(data)
18+
# GET health
19+
resp = Http.get("http://localhost:8080/health")
20+
check resp,
21+
"health status 200" => ->(r) { r.status == 200 },
22+
"has status field" => ->(r) { r.json&.key?("status") }
23+
24+
sleep_dur think_time("100ms", "500ms")
25+
26+
# GET users
27+
resp = Http.get("http://localhost:8080/api/users")
28+
check resp,
29+
"users status 200" => ->(r) { r.status == 200 }
30+
end
31+
32+
def teardown(data)
33+
end

examples/basic_test.star

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# basic_test.star - Basic load test example (Starlark)
2+
3+
scenario(
4+
name = "basic-health-check",
5+
pattern = chaos(preset = "gentle"),
6+
thresholds = {
7+
"http_req_duration{p95}": "< 500ms",
8+
"http_req_failed": "< 0.05",
9+
},
10+
)
11+
12+
def default(data):
13+
resp = http.get("http://localhost:8080/health")
14+
check(resp, {
15+
"status is 200": lambda r: r.status == 200,
16+
})

examples/checkout_test.star

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# checkout_test.star - Multi-step user flow with chaos patterns
2+
3+
scenario(
4+
name = "checkout-flow",
5+
pattern = chaos(
6+
preset = "aggressive",
7+
spike_factor = 3.0,
8+
),
9+
vus = ramp([
10+
stage("10s", 5),
11+
stage("30s", 20),
12+
stage("10s", 0),
13+
]),
14+
thresholds = {
15+
"http_req_duration{p95}": "< 1000ms",
16+
"http_req_failed": "< 0.1",
17+
"checks": "> 0.9",
18+
},
19+
)
20+
21+
def setup():
22+
resp = http.post("http://localhost:8080/api/echo", json = {
23+
"action": "auth",
24+
"user": "loadtest",
25+
})
26+
return {"session": "test-session-id"}
27+
28+
def default(data):
29+
headers = {"X-Session": data["session"]}
30+
31+
# Step 1: List products
32+
resp = http.get("http://localhost:8080/api/users", headers = headers)
33+
check(resp, {
34+
"list ok": lambda r: r.status == 200,
35+
})
36+
37+
# Think time — compresses during chaos spikes
38+
sleep(think_time("500ms", "2s"))
39+
40+
# Step 2: Get stats
41+
resp = http.get("http://localhost:8080/api/stats", headers = headers)
42+
check(resp, {
43+
"stats ok": lambda r: r.status == 200,
44+
})
45+
46+
def teardown(data):
47+
http.post("http://localhost:8080/api/echo", json = {"action": "logout"})

examples/echoserver/main.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,24 @@ func handleHealth(w http.ResponseWriter, r *http.Request) {
112112
atomic.AddInt64(&stats.TotalRequests, 1)
113113
atomic.AddInt64(&stats.GetRequests, 1)
114114

115-
// Simulate occasional slow responses
115+
// 10% chance of failure: 500 error + 1~3s delay
116+
if rand.Float32() < 0.10 {
117+
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
118+
time.Sleep(delay)
119+
atomic.AddInt64(&stats.Errors, 1)
120+
w.Header().Set("Content-Type", "application/json")
121+
w.WriteHeader(http.StatusInternalServerError)
122+
json.NewEncoder(w).Encode(map[string]interface{}{
123+
"status": "error",
124+
"error": "internal server error",
125+
"delay": delay.String(),
126+
})
127+
return
128+
}
129+
130+
// 5% chance of slow response (no error)
116131
if rand.Float32() < 0.05 {
117-
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
132+
time.Sleep(time.Duration(200+rand.Intn(500)) * time.Millisecond)
118133
}
119134

120135
w.Header().Set("Content-Type", "application/json")
@@ -171,7 +186,35 @@ func handleUserByID(w http.ResponseWriter, r *http.Request) {
171186
}
172187
}
173188

189+
func maybeFail(w http.ResponseWriter) bool {
190+
roll := rand.Float32()
191+
// 10% → 500 with 1~3s delay
192+
if roll < 0.10 {
193+
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
194+
time.Sleep(delay)
195+
atomic.AddInt64(&stats.Errors, 1)
196+
w.Header().Set("Content-Type", "application/json")
197+
w.WriteHeader(http.StatusInternalServerError)
198+
json.NewEncoder(w).Encode(map[string]string{"error": "internal server error"})
199+
return true
200+
}
201+
// 5% → 400
202+
if roll < 0.15 {
203+
atomic.AddInt64(&stats.Errors, 1)
204+
w.Header().Set("Content-Type", "application/json")
205+
w.WriteHeader(http.StatusBadRequest)
206+
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})
207+
return true
208+
}
209+
// 3% → slow (no error)
210+
if roll < 0.18 {
211+
time.Sleep(time.Duration(200+rand.Intn(500)) * time.Millisecond)
212+
}
213+
return false
214+
}
215+
174216
func listUsers(w http.ResponseWriter, r *http.Request) {
217+
if maybeFail(w) { return }
175218
store.mu.RLock()
176219
defer store.mu.RUnlock()
177220

@@ -285,6 +328,8 @@ func handleEcho(w http.ResponseWriter, r *http.Request) {
285328
atomic.AddInt64(&stats.TotalRequests, 1)
286329
atomic.AddInt64(&stats.PostRequests, 1)
287330

331+
if maybeFail(w) { return }
332+
288333
w.Header().Set("Content-Type", "application/json")
289334

290335
var body interface{}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ require (
2727
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2828
github.com/clipperhouse/stringish v0.1.1 // indirect
2929
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
30+
github.com/dlclark/regexp2 v1.11.4 // indirect
31+
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
3032
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
33+
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
34+
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
3135
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3236
github.com/kr/text v0.2.0 // indirect
3337
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -44,6 +48,7 @@ require (
4448
github.com/rivo/uniseg v0.4.7 // indirect
4549
github.com/spf13/pflag v1.0.9 // indirect
4650
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
51+
go.starlark.net v0.0.0-20260326113308-fadfc96def35 // indirect
4752
go.yaml.in/yaml/v2 v2.4.2 // indirect
4853
golang.org/x/sys v0.42.0 // indirect
4954
golang.org/x/text v0.35.0 // indirect

0 commit comments

Comments
 (0)