diff --git a/CLAUDE.md b/CLAUDE.md index 511cb64..5873ac0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,152 @@ github.com/deicon/httprunner/ ./httprunner -u 20 -i 10 -d 1000 -f requests.http ``` +## K6 Export + +httprunner can convert .http files to [k6](https://k6.io/) JavaScript test scripts, enabling you to leverage k6's advanced load testing features while maintaining your existing .http request definitions. + +### Usage + +```bash +./httprunner -convert k6 -f requests.http > test.js +``` + +### Parameters + +- `-convert k6`: Converts the .http file to k6 format +- `-f filename`: Source .http file to convert (required) +- `-i n`: Number of iterations for the k6 script (default: 1) +- `-d n`: Delay between requests in milliseconds (default: 0) +- `-e filename`: .env file containing environment variable defaults + +### Example + +```bash +# Basic conversion +./httprunner -convert k6 -f requests.http > test.js + +# With custom iterations and delay +./httprunner -convert k6 -i 100 -d 500 -f requests.http > load-test.js + +# With environment variable defaults +./httprunner -convert k6 -e .env -f requests.http > test.js + +# Run the generated k6 script +k6 run test.js +``` + +### How It Works + +The converter transforms your .http file into a k6-compatible JavaScript script: + +1. **Template Variables**: Converts httprunner placeholders `{{.VARIABLE}}` to k6 template literals `${vars.VARIABLE}` +2. **Environment Defaults**: Loads defaults from the .env file (if provided) for discovered placeholder variables +3. **Variable Override**: Generated scripts use k6's `__ENV` to allow runtime variable overrides via `-e KEY=VALUE` +4. **Pre/Post Scripts**: Preserves JavaScript pre-request and post-request scripts with appropriate context +5. **Request Chaining**: Maintains request order and dependencies through global variable sharing + +### Generated Script Structure + +The k6 script includes: + +- **Options block**: Configures iterations based on `-i` parameter +- **Defaults object**: Contains environment variable defaults from .env file +- **Vars proxy**: Enables runtime variable override via k6's `__ENV` or script-level assignment +- **Client API**: Provides `client.global.set/get` for variable management compatible with httprunner scripts +- **Request execution**: Converts HTTP requests to appropriate k6 `http.*` method calls +- **Sleep delays**: Adds `sleep()` calls between requests based on `-d` parameter + +### Example Conversion + +**Input (requests.http):** +``` +### +# @name Create User +> {% +client.global.set("timestamp", Date.now().toString()) +%} + +POST {{.BASEURL}}/api/users +Authorization: Bearer {{.TOKEN}} +Content-Type: application/json + +{ + "username": "testuser", + "timestamp": "{{.timestamp}}" +} + +> {% +var jsonData = response.body +client.global.set("userId", jsonData.id) +%} + +### +# @name Get User +GET {{.BASEURL}}/api/users/{{.userId}} +Authorization: Bearer {{.TOKEN}} +``` + +**Output (k6 script):** +```javascript +import http from 'k6/http'; +import { sleep } from 'k6'; + +export const options = { + iterations: 1, +}; + +const defaults = {BASEURL: 'http://localhost:8080', TOKEN: 'secret123'}; +const vars = new Proxy(Object.assign({}, defaults), { + get: (t, p) => (typeof __ENV !== 'undefined' && __ENV[p] !== undefined ? __ENV[p] : t[p]), + set: (t, p, v) => { t[p] = v; return true; }, +}); + +const client = { global: { set: (k, v) => { vars[k] = v; }, get: (k) => vars[k] } }; +function safeJson(s) { try { return JSON.parse(s); } catch (_) { return s; } } + +export default function () { + let response; + // Pre-script + client.global.set("timestamp", Date.now().toString()) + + const httpRes_0 = http.post(`${vars.BASEURL}/api/users`, `{ + "username": "testuser", + "timestamp": "${vars.timestamp}" +}`, { headers: {'Authorization': `Bearer ${vars.TOKEN}`, 'Content-Type': 'application/json'} }); + const response_0 = { body: safeJson(httpRes_0.body), status: httpRes_0.status, headers: httpRes_0.headers }; + response = response_0; + // Post-script + var jsonData = response.body + client.global.set("userId", jsonData.id) + + const httpRes_1 = http.get(`${vars.BASEURL}/api/users/${vars.userId}`, { headers: {'Authorization': `Bearer ${vars.TOKEN}`} }); + const response_1 = { body: safeJson(httpRes_1.body), status: httpRes_1.status, headers: httpRes_1.headers }; + response = response_1; +} +``` + +### Running k6 Tests + +```bash +# Run with default environment variables from .env +k6 run test.js + +# Override environment variables at runtime +k6 run -e BASEURL=https://api.example.com -e TOKEN=xyz123 test.js + +# Scale up with virtual users +k6 run --vus 50 --duration 30s test.js + +# Use k6 cloud for distributed testing +k6 cloud test.js +``` + +### Limitations + +- **Checks**: httprunner `client.check()` calls are not automatically converted to k6 checks. Manual adaptation may be required. +- **Metrics**: httprunner's `client.metrics` API is not available in k6. Use k6's native metrics and custom metrics instead. +- **Lifecycle requests**: Requests marked with lifecycle stages (setup/teardown) are skipped in conversion with comments. + ## HTTP Request File Format Requests are separated by `###` and follow this format: diff --git a/cmd/httprunner/convert_k6.go b/cmd/httprunner/convert_k6.go new file mode 100644 index 0000000..813745a --- /dev/null +++ b/cmd/httprunner/convert_k6.go @@ -0,0 +1,18 @@ +package main + +import ( + k6conv "github.com/deicon/httprunner/converter/k6" + chttp "github.com/deicon/httprunner/http" +) + +// requireK6Generate is isolated to avoid import when not used +func requireK6Generate(requests []chttp.Request, opts struct { + Iterations, DelayMS int + EnvFile string +}) (string, error) { + return k6conv.Generate(requests, k6conv.Options{ + Iterations: opts.Iterations, + DelayMS: opts.DelayMS, + EnvFile: opts.EnvFile, + }) +} diff --git a/cmd/httprunner/main.go b/cmd/httprunner/main.go index 7834f41..6bccd12 100644 --- a/cmd/httprunner/main.go +++ b/cmd/httprunner/main.go @@ -29,6 +29,7 @@ func main() { offset := flag.Int("offset", 0, "Max random startup delay per VU in milliseconds") requestFile := flag.String("f", "", ".http file containing http requests") envFile := flag.String("e", "", ".env file containing environment variables") + convert := flag.String("convert", "", "Convert input file to target format (e.g., k6) and print to stdout") reportFormat := flag.String("report", "console", "Report format: console, html, csv, json") reportOutput := flag.String("output", "results", "Output directory for results and reports") reportDetail := flag.String("detail", "summary", "Report detail level: summary, goroutine, iteration, full") @@ -50,6 +51,43 @@ func main() { os.Exit(1) } + // Conversion mode: when -convert is provided, parse and emit converted output + if *convert != "" { + requests, err := parser.Parse(*requestFile) + if err != nil { + fmt.Printf("Error parsing file: %v\n", err) + os.Exit(1) + } + + switch strings.ToLower(*convert) { + case "k6": + // Generate K6 script + genOpts := struct { + Iterations int + DelayMS int + EnvFile string + }{ + Iterations: *iterations, + DelayMS: *delay, + EnvFile: *envFile, + } + // Import and call converter + k6gen, err := func() (string, error) { + // Local import to avoid unused import when not converting + return requireK6Generate(requests, genOpts) + }() + if err != nil { + fmt.Printf("Error generating k6 script: %v\n", err) + os.Exit(1) + } + fmt.Print(k6gen) + return + default: + fmt.Printf("Error: unknown converter '%s'\n", *convert) + os.Exit(1) + } + } + // If -csv shorthand is provided, force CSV formatting and summary detail; // also prefer printing to stdout for easy redirection. forceStdout := false diff --git a/converter/k6/generator.go b/converter/k6/generator.go new file mode 100644 index 0000000..902200b --- /dev/null +++ b/converter/k6/generator.go @@ -0,0 +1,270 @@ +package k6 + +import ( + "fmt" + "regexp" + "sort" + "strings" + + chttp "github.com/deicon/httprunner/http" + "github.com/deicon/httprunner/template" +) + +// Options controls K6 generation behavior +type Options struct { + Iterations int + DelayMS int + EnvFile string +} + +// Generate converts parsed .http requests into a K6 JS script. +// It preserves placeholders by converting {{.var}} to ${vars.var} and +// initializes defaults from the provided environment file for discovered keys only. +func Generate(requests []chttp.Request, opts Options) (string, error) { + if opts.Iterations <= 0 { + opts.Iterations = 1 + } + + // Discover placeholder keys across all requests + placeholderKeys := collectPlaceholderKeys(requests) + + // Load defaults from env file (and OS env), filtered to discovered keys only + defaults := map[string]string{} + if len(placeholderKeys) > 0 { + store := template.NewGlobalStore() + if opts.EnvFile != "" { + if err := store.LoadEnvFile(opts.EnvFile); err != nil { + return "", err + } + } + for _, k := range placeholderKeys { + if v, ok := store.GetAll()[k]; ok { + defaults[k] = fmt.Sprintf("%v", v) + } + } + } + + var b strings.Builder + // Header + b.WriteString("import http from 'k6/http';\n") + b.WriteString("import { sleep } from 'k6';\n\n") + b.WriteString(fmt.Sprintf("export const options = {\n iterations: %d,\n};\n\n", opts.Iterations)) + + // Defaults and vars proxy + b.WriteString("// Defaults derived from environment (.env) for discovered placeholders\n") + b.WriteString("const defaults = {") + if len(defaults) > 0 { + keys := make([]string, 0, len(defaults)) + for k := range defaults { + keys = append(keys, k) + } + sort.Strings(keys) + first := true + for _, k := range keys { + if !first { + b.WriteString(", ") + } else { + first = false + } + b.WriteString(jsProp(k)) + b.WriteString(": ") + b.WriteString(jsStringSingle(defaults[k])) + } + } + b.WriteString("};\n") + + b.WriteString("const vars = new Proxy(Object.assign({}, defaults), {\n") + b.WriteString(" get: (t, p) => (typeof __ENV !== 'undefined' && __ENV[p] !== undefined ? __ENV[p] : t[p]),\n") + b.WriteString(" set: (t, p, v) => { t[p] = v; return true; },\n") + b.WriteString("});\n\n") + + b.WriteString("const client = { global: { set: (k, v) => { vars[k] = v; }, get: (k) => vars[k] } };\n") + b.WriteString("function safeJson(s) { try { return JSON.parse(s); } catch (_) { return s; } }\n\n") + + // Main function + b.WriteString("export default function () {\n") + b.WriteString(" let response;\n") + + delay := opts.DelayMS + for idx, req := range requests { + if req.Lifecycle != chttp.LifecycleNone { + b.WriteString(" // Skipping lifecycle request: ") + if req.Name != "" { + b.WriteString(jsStringLiteralComment(req.Name)) + } else { + b.WriteString(jsStringLiteralComment(req.Verb + " " + req.URL)) + } + b.WriteString("\n") + continue + } + + if strings.TrimSpace(req.PreScript) != "" { + b.WriteString(" // Pre-script\n") + b.WriteString(indentJS(req.PreScript, 2)) + b.WriteString("\n") + } + + hasHTTP := strings.TrimSpace(req.Verb) != "" && strings.TrimSpace(req.URL) != "" + var respVar string + if hasHTTP { + url := toTemplateOrString(req.URL) + headersObj := buildHeadersObject(req.Headers) + + method := strings.ToUpper(req.Verb) + switch method { + case "GET", "DELETE": + if method == "GET" { + b.WriteString(fmt.Sprintf(" const httpRes_%d = http.get(%s, { headers: %s });\n", idx, url, headersObj)) + } else { + b.WriteString(fmt.Sprintf(" const httpRes_%d = http.del(%s, null, { headers: %s });\n", idx, url, headersObj)) + } + case "POST", "PUT", "PATCH": + body := toTemplateOrString(req.Body) + fn := "post" + if method == "PUT" { + fn = "put" + } + if method == "PATCH" { + fn = "patch" + } + b.WriteString(fmt.Sprintf(" const httpRes_%d = http.%s(%s, %s, { headers: %s });\n", idx, fn, url, body, headersObj)) + default: + body := toTemplateOrString(req.Body) + b.WriteString(fmt.Sprintf(" const httpRes_%d = http.request(%s, %s, %s, { headers: %s });\n", idx, jsStringSingle(method), url, body, headersObj)) + } + + b.WriteString(fmt.Sprintf(" const response_%d = { body: safeJson(httpRes_%d.body), status: httpRes_%d.status, headers: httpRes_%d.headers };\n", idx, idx, idx, idx)) + b.WriteString(fmt.Sprintf(" response = response_%d;\n", idx)) + respVar = fmt.Sprintf("response_%d", idx) + } + + if strings.TrimSpace(req.Script) != "" { + b.WriteString(" // Post-script\n") + b.WriteString(indentJS(req.Script, 2)) + b.WriteString("\n") + } + + if delay > 0 && idx < len(requests)-1 { + b.WriteString(fmt.Sprintf(" sleep(%g);\n", float64(delay)/1000.0)) + } + + _ = respVar + } + + b.WriteString("}\n") + return b.String(), nil +} + +var placeholderRe = regexp.MustCompile(`\{\{\s*\.([a-zA-Z0-9_]+)\s*\}\}`) + +func collectPlaceholderKeys(requests []chttp.Request) []string { + keysSet := map[string]struct{}{} + add := func(s string) { + for _, m := range placeholderRe.FindAllStringSubmatch(s, -1) { + if len(m) > 1 { + keysSet[m[1]] = struct{}{} + } + } + } + for _, r := range requests { + add(r.URL) + for k, v := range r.Headers { + add(k) + add(v) + } + add(r.Body) + add(r.PreScript) + add(r.Script) + } + keys := make([]string, 0, len(keysSet)) + for k := range keysSet { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Replace placeholders with JS template placeholders and choose quoting. +func toTemplateOrString(s string) string { + if s == "" { + return jsStringSingle("") + } + if placeholderRe.MatchString(s) { + repl := placeholderRe.ReplaceAllStringFunc(s, func(m string) string { + sm := placeholderRe.FindStringSubmatch(m) + if len(sm) > 1 { + return "${vars." + sm[1] + "}" + } + return m + }) + return jsStringTemplate(repl) + } + return jsStringSingle(s) +} + +// Build headers object as JS literal +func buildHeadersObject(headers map[string]string) string { + if len(headers) == 0 { + return "{}" + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + v := headers[k] + parts = append(parts, fmt.Sprintf("%s: %s", jsStringSingle(k), toTemplateOrString(v))) + } + return "{" + strings.Join(parts, ", ") + "}" +} + +// jsStringSingle encodes as single-quoted string with escapes +func jsStringSingle(s string) string { + esc := strings.ReplaceAll(s, "\\", "\\\\") + esc = strings.ReplaceAll(esc, "'", "\\'") + esc = strings.ReplaceAll(esc, "\n", "\\n") + esc = strings.ReplaceAll(esc, "\r", "\\r") + esc = strings.ReplaceAll(esc, "\t", "\\t") + return "'" + esc + "'" +} + +// jsStringTemplate encodes as backtick template, escaping backticks but allowing ${} +func jsStringTemplate(s string) string { + esc := strings.ReplaceAll(s, "`", "\\`") + return "`" + esc + "`" +} + +// jsProp renders a safe JS identifier or quoted key +func jsProp(k string) string { + if len(k) > 0 && ((k[0] >= 'a' && k[0] <= 'z') || (k[0] >= 'A' && k[0] <= 'Z') || k[0] == '_') { + for i := 1; i < len(k); i++ { + c := k[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return jsStringSingle(k) + } + } + return k + } + return jsStringSingle(k) +} + +// indentJS indents JS code by n spaces at each line +func indentJS(code string, n int) string { + pad := strings.Repeat(" ", n) + lines := strings.Split(code, "\n") + for i, l := range lines { + if strings.TrimSpace(l) == "" { + lines[i] = "" + } else { + lines[i] = pad + l + } + } + return strings.Join(lines, "\n") +} + +func jsStringLiteralComment(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + return s +} diff --git a/converter/k6/generator_test.go b/converter/k6/generator_test.go new file mode 100644 index 0000000..ac066ab --- /dev/null +++ b/converter/k6/generator_test.go @@ -0,0 +1,96 @@ +package k6 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deicon/httprunner/parser" +) + +func writeTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + return p +} + +func TestGenerate_K6_SimpleChainAndDelay(t *testing.T) { + t.Parallel() + dir := t.TempDir() + httpPath := writeTempFile(t, dir, "loadtest.http", `### +GET https://jsonplaceholder.typicode.com/todos/1 +Content-Type: application/json + +> {% + client.global.set("userId", response.body.userId); +%} + +### +GET https://jsonplaceholder.typicode.com/posts/{{.userId}} +Content-Type: application/json + +> {% + console.log(response.body) +%} +`) + + reqs, err := parser.Parse(httpPath) + if err != nil { + t.Fatalf("parse: %v", err) + } + + js, err := Generate(reqs, Options{Iterations: 100, DelayMS: 2000}) + if err != nil { + t.Fatalf("generate: %v", err) + } + + // Basic assertions + if !strings.Contains(js, "export const options = {\n iterations: 100,") { + t.Fatalf("missing iterations in options, got:\n%s", js) + } + if !strings.Contains(js, "sleep(2)") { + t.Fatalf("missing sleep(2) for 2000ms delay, got:\n%s", js) + } + if !strings.Contains(js, "jsonplaceholder.typicode.com/posts/${vars.userId}") { + t.Fatalf("placeholder not converted to vars proxy, got:\n%s", js) + } +} + +func TestGenerate_K6_EnvDefaultsFromFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Env file providing default value for TOKEN used in placeholders + envPath := writeTempFile(t, dir, "local.env", "TOKEN=abc123\n") + + httpPath := writeTempFile(t, dir, "envtest.http", `### +GET https://api.example.com/data/{{.TOKEN}} +Accept: application/json +`) + + reqs, err := parser.Parse(httpPath) + if err != nil { + t.Fatalf("parse: %v", err) + } + + js, err := Generate(reqs, Options{Iterations: 1, DelayMS: 0, EnvFile: envPath}) + if err != nil { + t.Fatalf("generate: %v", err) + } + + // Defaults should include TOKEN: 'abc123' + if !strings.Contains(js, "const defaults = {TOKEN: 'abc123'};") && + !strings.Contains(js, "const defaults = { TOKEN: 'abc123' }") && + !strings.Contains(js, "const defaults = {TOKEN: 'abc123'}") { + t.Fatalf("missing defaults token value, got:\n%s", js) + } + + // URL should use vars proxy placeholder + if !strings.Contains(js, "api.example.com/data/${vars.TOKEN}") { + t.Fatalf("missing runtime placeholder in URL, got:\n%s", js) + } +} diff --git a/docs/specs/k6-coverter.md b/docs/specs/k6-coverter.md new file mode 100644 index 0000000..8783e4f --- /dev/null +++ b/docs/specs/k6-coverter.md @@ -0,0 +1,84 @@ +# Conversion +This feature converts `.http` load test files into Grafana k6 scripts: +https://grafana.com/docs/k6/latest/get-started/write-your-first-test/ + +- Simple `.http` files with minimal pre/post scripts should be easily convertible. +- Delay `-d` (ms) is mapped to `sleep(d/1000)` between sequential requests. +- `-i` iterations are mapped to `export const options = { iterations: i }`. +- Use `httprunner -convert k6` to print the generated k6 script to stdout. + +Parameters for conversion +- `-f` input `.http` file +- `-i` iterations +- `-d` delay between requests in ms +- `-e` optional `.env` file for default values (see Environment) + +## Environment +- Placeholders are preserved and resolved at runtime in k6. The converter turns `{{.VAR}}` into `${vars.VAR}` in JS template strings. +- Defaults are derived from the provided `.env` file and current process environment, but only for variables referenced by placeholders. +- At runtime, `__ENV` overrides defaults: `vars[key]` reads from `__ENV[key]` when set, otherwise from defaults. Scripts can mutate variables via `client.global.set(key, value)`. + +# Example Input and expected output + +Input File _**loadtest.http**_ +``` +### +GET https://jsonplaceholder.typicode.com/todos/1 +Content-Type: application/json + +> {% + client.global.set("userId", response.body.userId); + console.log(response.body) +%} + +### +GET https://jsonplaceholder.typicode.com/posts/{{.userId}} +Content-Type: application/json + +> {% + console.log(response.body) +%} + +```` +Can be converted into K6 file +```bash +httprunner -e local.env -f loadtest.http -d 2000 -i 100 -convert k6 > loadtest.js +``` +which should result in something like + +```javascript +import http from 'k6/http'; +import { sleep } from 'k6'; + +export const options = { + iterations: 100, +}; + +// Defaults derived from environment (.env) for discovered placeholders +const defaults = { /* e.g., userId: '123' when present in -e */ }; +const vars = new Proxy(Object.assign({}, defaults), { + get: (t, p) => (typeof __ENV !== 'undefined' && __ENV[p] !== undefined ? __ENV[p] : t[p]), + set: (t, p, v) => { t[p] = v; return true; }, +}); + +const client = { global: { set: (k, v) => { vars[k] = v; }, get: (k) => vars[k] } }; +function safeJson(s) { try { return JSON.parse(s); } catch (_) { return s; } } + +export default function () { + let response; + const httpRes_0 = http.get('https://jsonplaceholder.typicode.com/todos/1', { headers: {'Content-Type': 'application/json'} }); + const response_0 = { body: safeJson(httpRes_0.body), status: httpRes_0.status, headers: httpRes_0.headers }; + response = response_0; + // Post-script + client.global.set("userId", response.body.userId); + sleep(2); + const httpRes_1 = http.get(`https://jsonplaceholder.typicode.com/posts/${vars.userId}`, { headers: {'Content-Type': 'application/json'} }); + const response_1 = { body: safeJson(httpRes_1.body), status: httpRes_1.status, headers: httpRes_1.headers }; + response = response_1; +} +``` + +## Notes and limitations +- Lifecycle annotations (`@BeforeUser`, `@BeforeIteration`, `@Teardown*`) are currently omitted from the generated k6 script. +- One pre and one post JS block per request are supported. +- Request bodies are emitted as strings; JSON is not auto-inferred beyond the headers provided. diff --git a/examples/k6/loadtest.http b/examples/k6/loadtest.http new file mode 100644 index 0000000..1b9fef5 --- /dev/null +++ b/examples/k6/loadtest.http @@ -0,0 +1,12 @@ +### +GET https://jsonplaceholder.typicode.com/todos/1 +Content-Type: application/json + +> {% + client.global.set("userId", response.body.userId); +%} + +### +GET https://jsonplaceholder.typicode.com/posts/{{.userId}} +Content-Type: application/json +