move files to hrp

This commit is contained in:
debugtalk
2022-03-23 15:10:23 +08:00
parent f8113ea364
commit 8268aa0822
125 changed files with 0 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# boomer
This module is initially forked from [myzhan/boomer] and made a lot of changes.
[myzhan/boomer]: https://github.com/myzhan/boomer
+171
View File
@@ -0,0 +1,171 @@
package boomer
import (
"math"
"os"
"os/signal"
"syscall"
"time"
"github.com/rs/zerolog/log"
)
// A Boomer is used to run tasks.
type Boomer struct {
localRunner *localRunner
cpuProfile string
cpuProfileDuration time.Duration
memoryProfile string
memoryProfileDuration time.Duration
disableKeepalive bool
disableCompression bool
}
// NewStandaloneBoomer returns a new Boomer, which can run without master.
func NewStandaloneBoomer(spawnCount int, spawnRate float64) *Boomer {
return &Boomer{
localRunner: newLocalRunner(spawnCount, spawnRate),
}
}
// SetRateLimiter creates rate limiter with the given limit and burst.
func (b *Boomer) SetRateLimiter(maxRPS int64, requestIncreaseRate string) {
var rateLimiter RateLimiter
var err error
if requestIncreaseRate != "-1" {
if maxRPS <= 0 {
maxRPS = math.MaxInt64
}
log.Warn().Int64("maxRPS", maxRPS).Str("increaseRate", requestIncreaseRate).Msg("set ramp up rate limiter")
rateLimiter, err = NewRampUpRateLimiter(maxRPS, requestIncreaseRate, time.Second)
} else {
if maxRPS > 0 {
log.Warn().Int64("maxRPS", maxRPS).Msg("set stable rate limiter")
rateLimiter = NewStableRateLimiter(maxRPS, time.Second)
}
}
if err != nil {
log.Error().Err(err).Msg("failed to create rate limiter")
return
}
if rateLimiter != nil {
b.localRunner.rateLimitEnabled = true
b.localRunner.rateLimiter = rateLimiter
}
}
// SetDisableKeepAlive disable keep-alive for tcp
func (b *Boomer) SetDisableKeepAlive(disableKeepalive bool) {
b.disableKeepalive = disableKeepalive
}
// SetDisableCompression disable compression to prevent the Transport from requesting compression with an "Accept-Encoding: gzip"
func (b *Boomer) SetDisableCompression(disableCompression bool) {
b.disableCompression = disableCompression
}
func (b *Boomer) GetDisableKeepAlive() bool {
return b.disableKeepalive
}
func (b *Boomer) GetDisableCompression() bool {
return b.disableCompression
}
// SetLoopCount set loop count for test.
func (b *Boomer) SetLoopCount(loopCount int64) {
b.localRunner.loop = &Loop{loopCount: loopCount}
}
// AddOutput accepts outputs which implements the boomer.Output interface.
func (b *Boomer) AddOutput(o Output) {
b.localRunner.addOutput(o)
}
// EnableCPUProfile will start cpu profiling after run.
func (b *Boomer) EnableCPUProfile(cpuProfile string, duration time.Duration) {
b.cpuProfile = cpuProfile
b.cpuProfileDuration = duration
}
// EnableMemoryProfile will start memory profiling after run.
func (b *Boomer) EnableMemoryProfile(memoryProfile string, duration time.Duration) {
b.memoryProfile = memoryProfile
b.memoryProfileDuration = duration
}
// EnableGracefulQuit catch SIGINT and SIGTERM signals to quit gracefully
func (b *Boomer) EnableGracefulQuit() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-c
b.Quit()
}()
}
// Run accepts a slice of Task and connects to the locust master.
func (b *Boomer) Run(tasks ...*Task) {
if b.cpuProfile != "" {
err := startCPUProfile(b.cpuProfile, b.cpuProfileDuration)
if err != nil {
log.Error().Err(err).Msg("failed to start cpu profiling")
}
}
if b.memoryProfile != "" {
err := startMemoryProfile(b.memoryProfile, b.memoryProfileDuration)
if err != nil {
log.Error().Err(err).Msg("failed to start memory profiling")
}
}
b.localRunner.setTasks(tasks)
b.localRunner.start()
}
// RecordTransaction reports a transaction stat.
func (b *Boomer) RecordTransaction(name string, success bool, elapsedTime int64, contentSize int64) {
b.localRunner.stats.transactionChan <- &transaction{
name: name,
success: success,
elapsedTime: elapsedTime,
contentSize: contentSize,
}
}
// RecordSuccess reports a success.
func (b *Boomer) RecordSuccess(requestType, name string, responseTime int64, responseLength int64) {
b.localRunner.stats.requestSuccessChan <- &requestSuccess{
requestType: requestType,
name: name,
responseTime: responseTime,
responseLength: responseLength,
}
}
// RecordFailure reports a failure.
func (b *Boomer) RecordFailure(requestType, name string, responseTime int64, exception string) {
b.localRunner.stats.requestFailureChan <- &requestFailure{
requestType: requestType,
name: name,
responseTime: responseTime,
errMsg: exception,
}
}
// Quit will send a quit message to the master.
func (b *Boomer) Quit() {
b.localRunner.stop()
}
func (b *Boomer) GetSpawnDoneChan() chan struct{} {
return b.localRunner.spawnDone
}
func (b *Boomer) GetSpawnCount() int {
return b.localRunner.spawnCount
}
+146
View File
@@ -0,0 +1,146 @@
package boomer
import (
"math"
"os"
"runtime"
"sync/atomic"
"testing"
"time"
)
func TestNewStandaloneBoomer(t *testing.T) {
b := NewStandaloneBoomer(100, 10)
if b.localRunner.spawnCount != 100 {
t.Error("spawnCount should be 100")
}
if b.localRunner.spawnRate != 10 {
t.Error("spawnRate should be 10")
}
}
func TestSetRateLimiter(t *testing.T) {
b := NewStandaloneBoomer(100, 10)
b.SetRateLimiter(10, "10/1s")
if b.localRunner.rateLimiter == nil {
t.Error("b.rateLimiter should not be nil")
}
}
func TestAddOutput(t *testing.T) {
b := NewStandaloneBoomer(100, 10)
b.AddOutput(NewConsoleOutput())
b.AddOutput(NewConsoleOutput())
if len(b.localRunner.outputs) != 2 {
t.Error("length of outputs should be 2")
}
}
func TestEnableCPUProfile(t *testing.T) {
b := NewStandaloneBoomer(100, 10)
b.EnableCPUProfile("cpu.prof", time.Second)
if b.cpuProfile != "cpu.prof" {
t.Error("cpuProfile should be cpu.prof")
}
if b.cpuProfileDuration != time.Second {
t.Error("cpuProfileDuration should 1 second")
}
}
func TestEnableMemoryProfile(t *testing.T) {
b := NewStandaloneBoomer(100, 10)
b.EnableMemoryProfile("mem.prof", time.Second)
if b.memoryProfile != "mem.prof" {
t.Error("memoryProfile should be mem.prof")
}
if b.memoryProfileDuration != time.Second {
t.Error("memoryProfileDuration should 1 second")
}
}
func TestStandaloneRun(t *testing.T) {
b := NewStandaloneBoomer(10, 10)
b.EnableCPUProfile("cpu.pprof", 2*time.Second)
b.EnableMemoryProfile("mem.pprof", 2*time.Second)
count := int64(0)
taskA := &Task{
Name: "increaseCount",
Fn: func() {
atomic.AddInt64(&count, 1)
runtime.Goexit()
},
}
go b.Run(taskA)
time.Sleep(5 * time.Second)
b.Quit()
if atomic.LoadInt64(&count) != 10 {
t.Error("count is", count, "expected: 10")
}
if _, err := os.Stat("cpu.pprof"); os.IsNotExist(err) {
t.Error("File cpu.pprof is not generated")
} else {
os.Remove("cpu.pprof")
}
if _, err := os.Stat("mem.pprof"); os.IsNotExist(err) {
t.Error("File mem.pprof is not generated")
} else {
os.Remove("mem.pprof")
}
}
func TestCreateRatelimiter(t *testing.T) {
b := NewStandaloneBoomer(10, 10)
b.SetRateLimiter(100, "-1")
if stableRateLimiter, ok := b.localRunner.rateLimiter.(*StableRateLimiter); !ok {
t.Error("Expected stableRateLimiter")
} else {
if stableRateLimiter.threshold != 100 {
t.Error("threshold should be equals to math.MaxInt64, was", stableRateLimiter.threshold)
}
}
b.SetRateLimiter(0, "1")
if rampUpRateLimiter, ok := b.localRunner.rateLimiter.(*RampUpRateLimiter); !ok {
t.Error("Expected rampUpRateLimiter")
} else {
if rampUpRateLimiter.maxThreshold != math.MaxInt64 {
t.Error("maxThreshold should be equals to math.MaxInt64, was", rampUpRateLimiter.maxThreshold)
}
if rampUpRateLimiter.rampUpRate != "1" {
t.Error("rampUpRate should be equals to \"1\", was", rampUpRateLimiter.rampUpRate)
}
}
b.SetRateLimiter(10, "2/2s")
if rampUpRateLimiter, ok := b.localRunner.rateLimiter.(*RampUpRateLimiter); !ok {
t.Error("Expected rampUpRateLimiter")
} else {
if rampUpRateLimiter.maxThreshold != 10 {
t.Error("maxThreshold should be equals to 10, was", rampUpRateLimiter.maxThreshold)
}
if rampUpRateLimiter.rampUpRate != "2/2s" {
t.Error("rampUpRate should be equals to \"2/2s\", was", rampUpRateLimiter.rampUpRate)
}
if rampUpRateLimiter.rampUpStep != 2 {
t.Error("rampUpStep should be equals to 2, was", rampUpRateLimiter.rampUpStep)
}
if rampUpRateLimiter.rampUpPeroid != 2*time.Second {
t.Error("rampUpPeroid should be equals to 2 seconds, was", rampUpRateLimiter.rampUpPeroid)
}
}
}
+532
View File
@@ -0,0 +1,532 @@
package boomer
import (
"fmt"
"os"
"sort"
"strconv"
"time"
"github.com/google/uuid"
"github.com/olekukonko/tablewriter"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/push"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp/internal/json"
)
// Output is primarily responsible for printing test results to different destinations
// such as consoles, files. You can write you own output and add to boomer.
// When running in standalone mode, the default output is ConsoleOutput, you can add more.
// When running in distribute mode, test results will be reported to master with or without
// an output.
// All the OnXXX function will be call in a separated goroutine, just in case some output will block.
// But it will wait for all outputs return to avoid data lost.
type Output interface {
// OnStart will be call before the test starts.
OnStart()
// By default, each output receive stats data from runner every three seconds.
// OnEvent is responsible for dealing with the data.
OnEvent(data map[string]interface{})
// OnStop will be called before the test ends.
OnStop()
}
// ConsoleOutput is the default output for standalone mode.
type ConsoleOutput struct {
}
// NewConsoleOutput returns a ConsoleOutput.
func NewConsoleOutput() *ConsoleOutput {
return &ConsoleOutput{}
}
func getMedianResponseTime(numRequests int64, responseTimes map[int64]int64) int64 {
medianResponseTime := int64(0)
if len(responseTimes) != 0 {
pos := (numRequests - 1) / 2
var sortedKeys []int64
for k := range responseTimes {
sortedKeys = append(sortedKeys, k)
}
sort.Slice(sortedKeys, func(i, j int) bool {
return sortedKeys[i] < sortedKeys[j]
})
for _, k := range sortedKeys {
if pos < responseTimes[k] {
medianResponseTime = k
break
}
pos -= responseTimes[k]
}
}
return medianResponseTime
}
func getAvgResponseTime(numRequests int64, totalResponseTime int64) (avgResponseTime float64) {
avgResponseTime = float64(0)
if numRequests != 0 {
avgResponseTime = float64(totalResponseTime) / float64(numRequests)
}
return avgResponseTime
}
func getAvgContentLength(numRequests int64, totalContentLength int64) (avgContentLength int64) {
avgContentLength = int64(0)
if numRequests != 0 {
avgContentLength = totalContentLength / numRequests
}
return avgContentLength
}
func getCurrentRps(numRequests int64, duration float64) (currentRps float64) {
currentRps = float64(numRequests) / duration
return currentRps
}
func getCurrentFailPerSec(numFailures int64, duration float64) (currentFailPerSec float64) {
currentFailPerSec = float64(numFailures) / duration
return currentFailPerSec
}
func getTotalFailRatio(totalRequests, totalFailures int64) (failRatio float64) {
if totalRequests == 0 {
return 0
}
return float64(totalFailures) / float64(totalRequests)
}
// OnStart of ConsoleOutput has nothing to do.
func (o *ConsoleOutput) OnStart() {
}
// OnStop of ConsoleOutput has nothing to do.
func (o *ConsoleOutput) OnStop() {
}
// OnEvent will print to the console.
func (o *ConsoleOutput) OnEvent(data map[string]interface{}) {
output, err := convertData(data)
if err != nil {
log.Error().Err(err).Msg("failed to convert data")
return
}
var state string
switch output.State {
case stateInit:
state = "initializing"
case stateSpawning:
state = "spawning"
case stateRunning:
state = "running"
case stateQuitting:
state = "quitting"
case stateStopped:
state = "stopped"
}
currentTime := time.Now()
println(fmt.Sprintf("Current time: %s, Users: %d, State: %s, Total RPS: %.1f, Total Average Response Time: %.1fms, Total Fail Ratio: %.1f%%",
currentTime.Format("2006/01/02 15:04:05"), output.UserCount, state, output.TotalRPS, output.TotalAvgResponseTime, output.TotalFailRatio*100))
println(fmt.Sprintf("Accumulated Transactions: %d Passed, %d Failed",
output.TransactionsPassed, output.TransactionsFailed))
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Type", "Name", "# requests", "# fails", "Median", "Average", "Min", "Max", "Content Size", "# reqs/sec", "# fails/sec"})
for _, stat := range output.Stats {
row := make([]string, 11)
row[0] = stat.Method
row[1] = stat.Name
row[2] = strconv.FormatInt(stat.NumRequests, 10)
row[3] = strconv.FormatInt(stat.NumFailures, 10)
row[4] = strconv.FormatInt(stat.medianResponseTime, 10)
row[5] = strconv.FormatFloat(stat.avgResponseTime, 'f', 2, 64)
row[6] = strconv.FormatInt(stat.MinResponseTime, 10)
row[7] = strconv.FormatInt(stat.MaxResponseTime, 10)
row[8] = strconv.FormatInt(stat.avgContentLength, 10)
row[9] = strconv.FormatFloat(stat.currentRps, 'f', 2, 64)
row[10] = strconv.FormatFloat(stat.currentFailPerSec, 'f', 2, 64)
table.Append(row)
}
table.Render()
println()
}
type statsEntryOutput struct {
statsEntry
medianResponseTime int64 // median response time
avgResponseTime float64 // average response time, round float to 2 decimal places
avgContentLength int64 // average content size
currentRps float64 // # reqs/sec
currentFailPerSec float64 // # fails/sec
}
type dataOutput struct {
UserCount int32 `json:"user_count"`
State int32 `json:"state"`
TotalStats *statsEntryOutput `json:"stats_total"`
TransactionsPassed int64 `json:"transactions_passed"`
TransactionsFailed int64 `json:"transactions_failed"`
TotalAvgResponseTime float64 `json:"total_avg_response_time"`
TotalRPS float64 `json:"total_rps"`
TotalFailRatio float64 `json:"total_fail_ratio"`
Stats []*statsEntryOutput `json:"stats"`
Errors map[string]map[string]interface{} `json:"errors"`
}
func convertData(data map[string]interface{}) (output *dataOutput, err error) {
userCount, ok := data["user_count"].(int32)
if !ok {
return nil, fmt.Errorf("user_count is not int32")
}
state, ok := data["state"].(int32)
if !ok {
return nil, fmt.Errorf("state is not int32")
}
stats, ok := data["stats"].([]interface{})
if !ok {
return nil, fmt.Errorf("stats is not []interface{}")
}
errors := data["errors"].(map[string]map[string]interface{})
transactions, ok := data["transactions"].(map[string]int64)
if !ok {
return nil, fmt.Errorf("transactions is not map[string]int64")
}
transactionsPassed := transactions["passed"]
transactionsFailed := transactions["failed"]
// convert stats in total
statsTotal, ok := data["stats_total"].(interface{})
if !ok {
return nil, fmt.Errorf("stats_total is not interface{}")
}
entryTotalOutput, err := deserializeStatsEntry(statsTotal)
if err != nil {
return nil, err
}
output = &dataOutput{
UserCount: userCount,
State: state,
TotalStats: entryTotalOutput,
TransactionsPassed: transactionsPassed,
TransactionsFailed: transactionsFailed,
TotalAvgResponseTime: entryTotalOutput.avgResponseTime,
TotalRPS: entryTotalOutput.currentRps,
TotalFailRatio: getTotalFailRatio(entryTotalOutput.NumRequests, entryTotalOutput.NumFailures),
Stats: make([]*statsEntryOutput, 0, len(stats)),
Errors: errors,
}
// convert stats
for _, stat := range stats {
entryOutput, err := deserializeStatsEntry(stat)
if err != nil {
return nil, err
}
output.Stats = append(output.Stats, entryOutput)
}
// sort stats by type
sort.Slice(output.Stats, func(i, j int) bool {
return output.Stats[i].Method < output.Stats[j].Method
})
return
}
func deserializeStatsEntry(stat interface{}) (entryOutput *statsEntryOutput, err error) {
statBytes, err := json.Marshal(stat)
if err != nil {
return nil, err
}
entry := statsEntry{}
if err = json.Unmarshal(statBytes, &entry); err != nil {
return nil, err
}
var duration float64
if entry.Name == "Total" {
duration = float64(entry.LastRequestTimestamp - entry.StartTime)
// fix: avoid divide by zero
if duration < 1 {
duration = 1
}
} else {
duration = float64(reportStatsInterval / time.Second)
}
numRequests := entry.NumRequests
entryOutput = &statsEntryOutput{
statsEntry: entry,
medianResponseTime: getMedianResponseTime(numRequests, entry.ResponseTimes),
avgResponseTime: getAvgResponseTime(numRequests, entry.TotalResponseTime),
avgContentLength: getAvgContentLength(numRequests, entry.TotalContentLength),
currentRps: getCurrentRps(numRequests, duration),
currentFailPerSec: getCurrentFailPerSec(entry.NumFailures, duration),
}
return
}
// gauge vectors for requests
var (
gaugeNumRequests = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "num_requests",
Help: "The number of requests",
},
[]string{"method", "name"},
)
gaugeNumFailures = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "num_failures",
Help: "The number of failures",
},
[]string{"method", "name"},
)
gaugeMedianResponseTime = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "median_response_time",
Help: "The median response time",
},
[]string{"method", "name"},
)
gaugeAverageResponseTime = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "average_response_time",
Help: "The average response time",
},
[]string{"method", "name"},
)
gaugeMinResponseTime = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "min_response_time",
Help: "The min response time",
},
[]string{"method", "name"},
)
gaugeMaxResponseTime = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "max_response_time",
Help: "The max response time",
},
[]string{"method", "name"},
)
gaugeAverageContentLength = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "average_content_length",
Help: "The average content length",
},
[]string{"method", "name"},
)
gaugeCurrentRPS = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "current_rps",
Help: "The current requests per second",
},
[]string{"method", "name"},
)
gaugeCurrentFailPerSec = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "current_fail_per_sec",
Help: "The current failure number per second",
},
[]string{"method", "name"},
)
)
// counter for total
var (
counterErrors = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "errors",
Help: "The errors of load testing",
},
[]string{"method", "name", "error"},
)
)
// summary for total
var (
summaryResponseTime = prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: "response_time",
Help: "The summary of response time",
Objectives: map[float64]float64{
0.5: 0.01,
0.9: 0.01,
0.95: 0.005,
},
AgeBuckets: 1,
MaxAge: 100000 * time.Second,
},
[]string{"method", "name"},
)
)
// gauges for total
var (
gaugeUsers = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "users",
Help: "The current number of users",
},
)
gaugeState = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "state",
Help: "The current runner state, 1=initializing, 2=spawning, 3=running, 4=quitting, 5=stopped",
},
)
gaugeTotalAverageResponseTime = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "total_average_response_time",
Help: "The average response time in total milliseconds",
},
)
gaugeTotalRPS = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "total_rps",
Help: "The requests per second in total",
},
)
gaugeTotalFailRatio = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "fail_ratio",
Help: "The ratio of request failures in total",
},
)
gaugeTransactionsPassed = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "transactions_passed",
Help: "The accumulated number of passed transactions",
},
)
gaugeTransactionsFailed = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "transactions_failed",
Help: "The accumulated number of failed transactions",
},
)
)
// NewPrometheusPusherOutput returns a PrometheusPusherOutput.
func NewPrometheusPusherOutput(gatewayURL, jobName string) *PrometheusPusherOutput {
nodeUUID, _ := uuid.NewUUID()
return &PrometheusPusherOutput{
pusher: push.New(gatewayURL, jobName).Grouping("instance", nodeUUID.String()),
}
}
// PrometheusPusherOutput pushes boomer stats to Prometheus Pushgateway.
type PrometheusPusherOutput struct {
pusher *push.Pusher // Prometheus Pushgateway Pusher
}
// OnStart will register all prometheus metric collectors
func (o *PrometheusPusherOutput) OnStart() {
log.Info().Msg("register prometheus metric collectors")
registry := prometheus.NewRegistry()
registry.MustRegister(
// gauge vectors for requests
gaugeNumRequests,
gaugeNumFailures,
gaugeMedianResponseTime,
gaugeAverageResponseTime,
gaugeMinResponseTime,
gaugeMaxResponseTime,
gaugeAverageContentLength,
gaugeCurrentRPS,
gaugeCurrentFailPerSec,
// counter for total
counterErrors,
// summary for total
summaryResponseTime,
// gauges for total
gaugeUsers,
gaugeState,
gaugeTotalAverageResponseTime,
gaugeTotalRPS,
gaugeTotalFailRatio,
gaugeTransactionsPassed,
gaugeTransactionsFailed,
)
o.pusher = o.pusher.Gatherer(registry)
}
// OnStop of PrometheusPusherOutput has nothing to do.
func (o *PrometheusPusherOutput) OnStop() {
// update runner state: stopped
gaugeState.Set(float64(stateStopped))
if err := o.pusher.Push(); err != nil {
log.Error().Err(err).Msg("push to Pushgateway failed")
}
}
// OnEvent will push metric to Prometheus Pushgataway
func (o *PrometheusPusherOutput) OnEvent(data map[string]interface{}) {
output, err := convertData(data)
if err != nil {
log.Error().Err(err).Msg("failed to convert data")
return
}
// user count
gaugeUsers.Set(float64(output.UserCount))
// runner state
gaugeState.Set(float64(output.State))
// avg response time in total
gaugeTotalAverageResponseTime.Set(output.TotalAvgResponseTime)
// rps in total
gaugeTotalRPS.Set(output.TotalRPS)
// failure ratio in total
gaugeTotalFailRatio.Set(output.TotalFailRatio)
// accumulated number of transactions
gaugeTransactionsPassed.Set(float64(output.TransactionsPassed))
gaugeTransactionsFailed.Set(float64(output.TransactionsFailed))
for _, stat := range output.Stats {
method := stat.Method
name := stat.Name
gaugeNumRequests.WithLabelValues(method, name).Set(float64(stat.NumRequests))
gaugeNumFailures.WithLabelValues(method, name).Set(float64(stat.NumFailures))
gaugeMedianResponseTime.WithLabelValues(method, name).Set(float64(stat.medianResponseTime))
gaugeAverageResponseTime.WithLabelValues(method, name).Set(float64(stat.avgResponseTime))
gaugeMinResponseTime.WithLabelValues(method, name).Set(float64(stat.MinResponseTime))
gaugeMaxResponseTime.WithLabelValues(method, name).Set(float64(stat.MaxResponseTime))
gaugeAverageContentLength.WithLabelValues(method, name).Set(float64(stat.avgContentLength))
gaugeCurrentRPS.WithLabelValues(method, name).Set(stat.currentRps)
gaugeCurrentFailPerSec.WithLabelValues(method, name).Set(stat.currentFailPerSec)
for responseTime, count := range stat.ResponseTimes {
var i int64
for i = 0; i < count; i++ {
summaryResponseTime.WithLabelValues(method, name).Observe(float64(responseTime))
}
}
}
// errors
for _, requestError := range output.Errors {
counterErrors.WithLabelValues(
requestError["method"].(string),
requestError["name"].(string),
requestError["error"].(string),
).Add(float64(requestError["occurrences"].(int64)))
}
if err := o.pusher.Push(); err != nil {
log.Error().Err(err).Msg("push to Pushgateway failed")
}
}
+104
View File
@@ -0,0 +1,104 @@
package boomer
import (
"fmt"
"math"
"testing"
)
func TestGetMedianResponseTime(t *testing.T) {
numRequests := int64(10)
responseTimes := map[int64]int64{
100: 1,
200: 3,
300: 6,
}
medianResponseTime := getMedianResponseTime(numRequests, responseTimes)
if medianResponseTime != 300 {
t.Error("medianResponseTime should be 300")
}
responseTimes = map[int64]int64{}
medianResponseTime = getMedianResponseTime(numRequests, responseTimes)
if medianResponseTime != 0 {
t.Error("medianResponseTime should be 0")
}
}
func TestGetAvgResponseTime(t *testing.T) {
numRequests := int64(3)
totalResponseTime := int64(100)
avgResponseTime := getAvgResponseTime(numRequests, totalResponseTime)
if math.Dim(float64(33.33), avgResponseTime) > 0.01 {
t.Error("avgResponseTime should be close to 33.33")
}
avgResponseTime = getAvgResponseTime(int64(0), totalResponseTime)
if avgResponseTime != float64(0) {
t.Error("avgResponseTime should be close to 0")
}
}
func TestGetAvgContentLength(t *testing.T) {
numRequests := int64(3)
totalContentLength := int64(100)
avgContentLength := getAvgContentLength(numRequests, totalContentLength)
if avgContentLength != 33 {
t.Error("avgContentLength should be 33")
}
avgContentLength = getAvgContentLength(int64(0), totalContentLength)
if avgContentLength != 0 {
t.Error("avgContentLength should be 0")
}
}
func TestGetCurrentRps(t *testing.T) {
duration := float64(3)
numRequests := int64(6)
currentRps := getCurrentRps(numRequests, duration)
if currentRps != 2 {
t.Error("currentRps should be 2")
}
numRequests = int64(8)
currentRps = getCurrentRps(numRequests, duration)
if fmt.Sprintf("%.2f", currentRps) != "2.67" {
t.Error("currentRps should be 2.67")
}
}
func TestConsoleOutput(t *testing.T) {
o := NewConsoleOutput()
o.OnStart()
data := map[string]interface{}{}
stat := map[string]interface{}{}
data["stats"] = []interface{}{stat}
stat["name"] = "http"
stat["method"] = "post"
stat["num_requests"] = int64(100)
stat["num_failures"] = int64(10)
stat["response_times"] = map[int64]int64{
10: 1,
100: 99,
}
stat["total_response_time"] = int64(9910)
stat["min_response_time"] = int64(10)
stat["max_response_time"] = int64(100)
stat["total_content_length"] = int64(100000)
stat["num_reqs_per_sec"] = map[int64]int64{
1: 20,
2: 40,
3: 40,
}
o.OnEvent(data)
o.OnStop()
}
+230
View File
@@ -0,0 +1,230 @@
package boomer
import (
"errors"
"math"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// RateLimiter is used to put limits on task executions.
type RateLimiter interface {
// Start is used to enable the rate limiter.
// It can be implemented as a noop if not needed.
Start()
// Acquire() is called before executing a task.Fn function.
// If Acquire() returns true, the task.Fn function will be executed.
// If Acquire() returns false, the task.Fn function won't be executed this time, but Acquire() will be called very soon.
// It works like:
// for {
// blocked := rateLimiter.Acquire()
// if !blocked {
// task.Fn()
// }
// }
// Acquire() should block the caller until execution is allowed.
Acquire() bool
// Stop is used to disable the rate limiter.
// It can be implemented as a noop if not needed.
Stop()
}
// A StableRateLimiter uses the token bucket algorithm.
// the bucket is refilled according to the refill period, no burst is allowed.
type StableRateLimiter struct {
threshold int64
currentThreshold int64
refillPeriod time.Duration
broadcastChanMux *sync.RWMutex // avoid data race
broadcastChannel chan bool
quitChannel chan bool
}
// NewStableRateLimiter returns a StableRateLimiter.
func NewStableRateLimiter(threshold int64, refillPeriod time.Duration) (rateLimiter *StableRateLimiter) {
rateLimiter = &StableRateLimiter{
threshold: threshold,
currentThreshold: threshold,
refillPeriod: refillPeriod,
broadcastChanMux: new(sync.RWMutex),
broadcastChannel: make(chan bool),
}
return rateLimiter
}
// Start to refill the bucket periodically.
func (limiter *StableRateLimiter) Start() {
limiter.quitChannel = make(chan bool)
quitChannel := limiter.quitChannel
go func() {
for {
select {
case <-quitChannel:
return
default:
atomic.StoreInt64(&limiter.currentThreshold, limiter.threshold)
time.Sleep(limiter.refillPeriod)
close(limiter.broadcastChannel)
// avoid data race
limiter.broadcastChanMux.Lock()
limiter.broadcastChannel = make(chan bool)
limiter.broadcastChanMux.Unlock()
}
}
}()
}
// Acquire a token from the bucket, returns true if the bucket is exhausted.
func (limiter *StableRateLimiter) Acquire() (blocked bool) {
permit := atomic.AddInt64(&limiter.currentThreshold, -1)
if permit < 0 {
blocked = true
// block until the bucket is refilled
limiter.broadcastChanMux.Lock()
<-limiter.broadcastChannel
limiter.broadcastChanMux.Unlock()
} else {
blocked = false
}
return blocked
}
// Stop the rate limiter.
func (limiter *StableRateLimiter) Stop() {
close(limiter.quitChannel)
}
// ErrParsingRampUpRate is the error returned if the format of rampUpRate is invalid.
var ErrParsingRampUpRate = errors.New("ratelimiter: invalid format of rampUpRate, try \"1\" or \"1/1s\"")
// A RampUpRateLimiter uses the token bucket algorithm.
// the threshold is updated according to the warm up rate.
// the bucket is refilled according to the refill period, no burst is allowed.
type RampUpRateLimiter struct {
maxThreshold int64
nextThreshold int64
currentThreshold int64
refillPeriod time.Duration
rampUpRate string
rampUpStep int64
rampUpPeroid time.Duration
broadcastChanMux *sync.RWMutex // avoid data race
broadcastChannel chan bool
rampUpChannel chan bool
quitChannel chan bool
}
// NewRampUpRateLimiter returns a RampUpRateLimiter.
// Valid formats of rampUpRate are "1", "1/1s".
func NewRampUpRateLimiter(maxThreshold int64, rampUpRate string, refillPeriod time.Duration) (rateLimiter *RampUpRateLimiter, err error) {
rateLimiter = &RampUpRateLimiter{
maxThreshold: maxThreshold,
nextThreshold: 0,
currentThreshold: 0,
rampUpRate: rampUpRate,
refillPeriod: refillPeriod,
broadcastChanMux: new(sync.RWMutex),
broadcastChannel: make(chan bool),
}
rateLimiter.rampUpStep, rateLimiter.rampUpPeroid, err = rateLimiter.parseRampUpRate(rateLimiter.rampUpRate)
if err != nil {
return nil, err
}
return rateLimiter, nil
}
func (limiter *RampUpRateLimiter) parseRampUpRate(rampUpRate string) (rampUpStep int64, rampUpPeroid time.Duration, err error) {
if strings.Contains(rampUpRate, "/") {
tmp := strings.Split(rampUpRate, "/")
if len(tmp) != 2 {
return rampUpStep, rampUpPeroid, ErrParsingRampUpRate
}
rampUpStep, err := strconv.ParseInt(tmp[0], 10, 64)
if err != nil {
return rampUpStep, rampUpPeroid, ErrParsingRampUpRate
}
rampUpPeroid, err := time.ParseDuration(tmp[1])
if err != nil {
return rampUpStep, rampUpPeroid, ErrParsingRampUpRate
}
return rampUpStep, rampUpPeroid, nil
}
rampUpStep, err = strconv.ParseInt(rampUpRate, 10, 64)
if err != nil {
return rampUpStep, rampUpPeroid, ErrParsingRampUpRate
}
rampUpPeroid = time.Second
return rampUpStep, rampUpPeroid, nil
}
// Start to refill the bucket periodically.
func (limiter *RampUpRateLimiter) Start() {
limiter.quitChannel = make(chan bool)
quitChannel := limiter.quitChannel
// bucket updater
go func() {
for {
select {
case <-quitChannel:
return
default:
atomic.StoreInt64(&limiter.currentThreshold, atomic.LoadInt64(&limiter.nextThreshold))
time.Sleep(limiter.refillPeriod)
close(limiter.broadcastChannel)
// avoid data race
limiter.broadcastChanMux.Lock()
limiter.broadcastChannel = make(chan bool)
limiter.broadcastChanMux.Unlock()
}
}
}()
// threshold updater
go func() {
for {
select {
case <-quitChannel:
return
default:
nextValue := atomic.LoadInt64(&limiter.nextThreshold) + limiter.rampUpStep
if nextValue < 0 {
// int64 overflow
nextValue = int64(math.MaxInt64)
}
if nextValue > limiter.maxThreshold {
nextValue = limiter.maxThreshold
}
atomic.StoreInt64(&limiter.nextThreshold, nextValue)
time.Sleep(limiter.rampUpPeroid)
}
}
}()
}
// Acquire a token from the bucket, returns true if the bucket is exhausted.
func (limiter *RampUpRateLimiter) Acquire() (blocked bool) {
permit := atomic.AddInt64(&limiter.currentThreshold, -1)
if permit < 0 {
blocked = true
// block until the bucket is refilled
limiter.broadcastChanMux.Lock()
<-limiter.broadcastChannel
limiter.broadcastChanMux.Unlock()
} else {
blocked = false
}
return blocked
}
// Stop the rate limiter.
func (limiter *RampUpRateLimiter) Stop() {
atomic.StoreInt64(&limiter.nextThreshold, 0)
close(limiter.quitChannel)
}
+102
View File
@@ -0,0 +1,102 @@
package boomer
import (
"testing"
"time"
)
func TestStableRateLimiter(t *testing.T) {
rateLimiter := NewStableRateLimiter(1, 10*time.Millisecond)
rateLimiter.Start()
defer rateLimiter.Stop()
blocked := rateLimiter.Acquire()
if blocked {
t.Error("Unexpected blocked by rate limiter")
}
blocked = rateLimiter.Acquire()
if !blocked {
t.Error("Should be blocked")
}
}
// FIXME
// func TestRampUpRateLimiter(t *testing.T) {
// rateLimiter, _ := NewRampUpRateLimiter(100, "10/200ms", 100*time.Millisecond)
// rateLimiter.Start()
// defer rateLimiter.Stop()
// time.Sleep(150 * time.Millisecond)
// for i := 0; i < 10; i++ {
// blocked := rateLimiter.Acquire()
// if blocked {
// t.Fatal("Unexpected blocked by rate limiter")
// }
// }
// blocked := rateLimiter.Acquire()
// if !blocked {
// t.Fatal("Should be blocked")
// }
// time.Sleep(150 * time.Millisecond)
// // now, the threshold is 20
// for i := 0; i < 20; i++ {
// blocked := rateLimiter.Acquire()
// if blocked {
// t.Fatal("Unexpected blocked by rate limiter")
// }
// }
// blocked = rateLimiter.Acquire()
// if !blocked {
// t.Fatal("Should be blocked")
// }
// }
func TestParseRampUpRate(t *testing.T) {
rateLimiter := &RampUpRateLimiter{}
rampUpStep, rampUpPeriod, _ := rateLimiter.parseRampUpRate("100")
if rampUpStep != 100 {
t.Error("Wrong rampUpStep, expected: 100, was:", rampUpStep)
}
if rampUpPeriod != time.Second {
t.Error("Wrong rampUpPeriod, expected: 1s, was:", rampUpPeriod)
}
rampUpStep, rampUpPeriod, _ = rateLimiter.parseRampUpRate("200/10s")
if rampUpStep != 200 {
t.Error("Wrong rampUpStep, expected: 200, was:", rampUpStep)
}
if rampUpPeriod != 10*time.Second {
t.Error("Wrong rampUpPeriod, expected: 10s, was:", rampUpPeriod)
}
}
func TestParseInvalidRampUpRate(t *testing.T) {
rateLimiter := &RampUpRateLimiter{}
_, _, err := rateLimiter.parseRampUpRate("A/1m")
if err == nil || err != ErrParsingRampUpRate {
t.Error("Expected ErrParsingRampUpRate")
}
_, _, err = rateLimiter.parseRampUpRate("A")
if err == nil || err != ErrParsingRampUpRate {
t.Error("Expected ErrParsingRampUpRate")
}
_, _, err = rateLimiter.parseRampUpRate("200/1s/")
if err == nil || err != ErrParsingRampUpRate {
t.Error("Expected ErrParsingRampUpRate")
}
_, _, err = rateLimiter.parseRampUpRate("200/1")
if err == nil || err != ErrParsingRampUpRate {
t.Error("Expected ErrParsingRampUpRate")
}
rateLimiter, err = NewRampUpRateLimiter(1, "200/1", time.Second)
if err == nil || err != ErrParsingRampUpRate {
t.Error("Expected ErrParsingRampUpRate")
}
}
+372
View File
@@ -0,0 +1,372 @@
package boomer
import (
"fmt"
"math/rand"
"os"
"runtime/debug"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/olekukonko/tablewriter"
"github.com/rs/zerolog/log"
)
const (
stateInit = iota + 1 // initializing
stateSpawning // spawning
stateRunning // running
stateQuitting // quitting
stateStopped // stopped
)
const (
reportStatsInterval = 3 * time.Second
)
type Loop struct {
loopCount int64 // more than 0
acquiredCount int64 // count acquired of load testing
finishedCount int64 // count finished of load testing
}
func (l *Loop) isFinished() bool {
// return true when there are no remaining loop count to test
return atomic.LoadInt64(&l.finishedCount) == l.loopCount
}
func (l *Loop) acquire() bool {
// get one ticket when there are still remaining loop count to test
// return true when getting ticket successfully
if atomic.LoadInt64(&l.acquiredCount) < l.loopCount {
atomic.AddInt64(&l.acquiredCount, 1)
return true
}
return false
}
func (l *Loop) increaseFinishedCount() {
atomic.AddInt64(&l.finishedCount, 1)
}
type runner struct {
state int32
tasks []*Task
totalTaskWeight int
rateLimiter RateLimiter
rateLimitEnabled bool
stats *requestStats
currentClientsNum int32 // current clients count
spawnCount int // target clients to spawn
spawnRate float64
loop *Loop // specify running cycles
spawnDone chan struct{}
outputs []Output
}
// safeRun runs fn and recovers from unexpected panics.
// it prevents panics from Task.Fn crashing boomer.
func (r *runner) safeRun(fn func()) {
defer func() {
// don't panic
err := recover()
if err != nil {
stackTrace := debug.Stack()
errMsg := fmt.Sprintf("%v", err)
os.Stderr.Write([]byte(errMsg))
os.Stderr.Write([]byte("\n"))
os.Stderr.Write(stackTrace)
}
}()
fn()
}
func (r *runner) addOutput(o Output) {
r.outputs = append(r.outputs, o)
}
func (r *runner) outputOnStart() {
size := len(r.outputs)
if size == 0 {
return
}
wg := sync.WaitGroup{}
wg.Add(size)
for _, output := range r.outputs {
go func(o Output) {
o.OnStart()
wg.Done()
}(output)
}
wg.Wait()
}
func (r *runner) outputOnEvent(data map[string]interface{}) {
size := len(r.outputs)
if size == 0 {
return
}
wg := sync.WaitGroup{}
wg.Add(size)
for _, output := range r.outputs {
go func(o Output) {
o.OnEvent(data)
wg.Done()
}(output)
}
wg.Wait()
}
func (r *runner) outputOnStop() {
size := len(r.outputs)
if size == 0 {
return
}
wg := sync.WaitGroup{}
wg.Add(size)
for _, output := range r.outputs {
go func(o Output) {
o.OnStop()
wg.Done()
}(output)
}
wg.Wait()
}
func (r *runner) reportStats() {
data := r.stats.collectReportData()
data["user_count"] = atomic.LoadInt32(&r.currentClientsNum)
data["state"] = atomic.LoadInt32(&r.state)
r.outputOnEvent(data)
}
func (r *runner) reportTestResult() {
// convert stats in total
var statsTotal interface{} = r.stats.total.serialize()
entryTotalOutput, err := deserializeStatsEntry(statsTotal)
if err != nil {
return
}
duration := time.Duration(entryTotalOutput.LastRequestTimestamp-entryTotalOutput.StartTime) * time.Second
currentTime := time.Now()
println(fmt.Sprint("=========================================== Statistics Summary =========================================="))
println(fmt.Sprintf("Current time: %s, Users: %v, Duration: %v, Accumulated Transactions: %d Passed, %d Failed",
currentTime.Format("2006/01/02 15:04:05"), atomic.LoadInt32(&r.currentClientsNum), duration, r.stats.transactionPassed, r.stats.transactionFailed))
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Name", "# requests", "# fails", "Median", "Average", "Min", "Max", "Content Size", "# reqs/sec", "# fails/sec"})
row := make([]string, 10)
row[0] = entryTotalOutput.Name
row[1] = strconv.FormatInt(entryTotalOutput.NumRequests, 10)
row[2] = strconv.FormatInt(entryTotalOutput.NumFailures, 10)
row[3] = strconv.FormatInt(entryTotalOutput.medianResponseTime, 10)
row[4] = strconv.FormatFloat(entryTotalOutput.avgResponseTime, 'f', 2, 64)
row[5] = strconv.FormatInt(entryTotalOutput.MinResponseTime, 10)
row[6] = strconv.FormatInt(entryTotalOutput.MaxResponseTime, 10)
row[7] = strconv.FormatInt(entryTotalOutput.avgContentLength, 10)
row[8] = strconv.FormatFloat(entryTotalOutput.currentRps, 'f', 2, 64)
row[9] = strconv.FormatFloat(entryTotalOutput.currentFailPerSec, 'f', 2, 64)
table.Append(row)
table.Render()
println()
}
func (r *localRunner) spawnWorkers(spawnCount int, spawnRate float64, quit chan bool, spawnCompleteFunc func()) {
log.Info().
Int("spawnCount", spawnCount).
Float64("spawnRate", spawnRate).
Msg("Spawning workers")
atomic.StoreInt32(&r.state, stateSpawning)
for i := 1; i <= spawnCount; i++ {
// spawn workers with rate limit
sleepTime := time.Duration(1000000/r.spawnRate) * time.Microsecond
time.Sleep(sleepTime)
select {
case <-quit:
// quit spawning goroutine
log.Info().Msg("Quitting spawning workers")
return
default:
atomic.AddInt32(&r.currentClientsNum, 1)
go func() {
for {
select {
case <-quit:
return
default:
if r.loop != nil && !r.loop.acquire() {
return
}
if r.rateLimitEnabled {
blocked := r.rateLimiter.Acquire()
if !blocked {
task := r.getTask()
r.safeRun(task.Fn)
}
} else {
task := r.getTask()
r.safeRun(task.Fn)
}
if r.loop != nil {
r.loop.increaseFinishedCount()
if r.loop.isFinished() {
r.stop()
}
}
}
}
}()
}
}
close(r.spawnDone)
if spawnCompleteFunc != nil {
spawnCompleteFunc()
}
atomic.StoreInt32(&r.state, stateRunning)
}
// setTasks will set the runner's task list AND the total task weight
// which is used to get a random task later
func (r *runner) setTasks(t []*Task) {
r.tasks = t
weightSum := 0
for _, task := range r.tasks {
weightSum += task.Weight
}
r.totalTaskWeight = weightSum
}
func (r *runner) getTask() *Task {
tasksCount := len(r.tasks)
if tasksCount == 1 {
// Fast path
return r.tasks[0]
}
rs := rand.New(rand.NewSource(time.Now().UnixNano()))
totalWeight := r.totalTaskWeight
if totalWeight <= 0 {
// If all the tasks have not weights defined, they have the same chance to run
randNum := rs.Intn(tasksCount)
return r.tasks[randNum]
}
randNum := rs.Intn(totalWeight)
runningSum := 0
for _, task := range r.tasks {
runningSum += task.Weight
if runningSum > randNum {
return task
}
}
return nil
}
type localRunner struct {
runner
// close this channel will stop all goroutines used in runner.
stopChan chan bool
}
func newLocalRunner(spawnCount int, spawnRate float64) *localRunner {
return &localRunner{
runner: runner{
state: stateInit,
spawnRate: spawnRate,
spawnCount: spawnCount,
stats: newRequestStats(),
outputs: make([]Output, 0),
spawnDone: make(chan struct{}),
},
stopChan: make(chan bool),
}
}
func (r *localRunner) start() {
// init state
atomic.StoreInt32(&r.state, stateInit)
atomic.StoreInt32(&r.currentClientsNum, 0)
r.stats.clearAll()
// start rate limiter
if r.rateLimitEnabled {
r.rateLimiter.Start()
}
// all running workers(goroutines) will select on this channel.
// close this channel will stop all running workers.
quitChan := make(chan bool)
// when this channel is closed, all statistics are reported successfully
reportedChan := make(chan bool)
go r.spawnWorkers(r.spawnCount, r.spawnRate, quitChan, nil)
// output setup
r.outputOnStart()
// start running
go func() {
var ticker = time.NewTicker(reportStatsInterval)
for {
select {
// record stats
case t := <-r.stats.transactionChan:
r.stats.logTransaction(t.name, t.success, t.elapsedTime, t.contentSize)
case m := <-r.stats.requestSuccessChan:
r.stats.logRequest(m.requestType, m.name, m.responseTime, m.responseLength)
case n := <-r.stats.requestFailureChan:
r.stats.logRequest(n.requestType, n.name, n.responseTime, 0)
r.stats.logError(n.requestType, n.name, n.errMsg)
// report stats
case <-ticker.C:
r.reportStats()
// close reportedChan and return if the last stats is reported successfully
if atomic.LoadInt32(&r.state) == stateQuitting {
close(reportedChan)
return
}
}
}
}()
// stop
<-r.stopChan
atomic.StoreInt32(&r.state, stateQuitting)
// stop previous goroutines without blocking
// those goroutines will exit when r.safeRun returns
close(quitChan)
// wait until all stats are reported successfully
<-reportedChan
// stop rate limiter
if r.rateLimitEnabled {
r.rateLimiter.Stop()
}
// report test result
r.reportTestResult()
// output teardown
r.outputOnStop()
atomic.StoreInt32(&r.state, stateStopped)
return
}
func (r *localRunner) stop() {
close(r.stopChan)
}
+116
View File
@@ -0,0 +1,116 @@
package boomer
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
type HitOutput struct {
onStart bool
onEvent bool
onStop bool
}
func (o *HitOutput) OnStart() {
o.onStart = true
}
func (o *HitOutput) OnEvent(data map[string]interface{}) {
o.onEvent = true
}
func (o *HitOutput) OnStop() {
o.onStop = true
}
func TestSafeRun(t *testing.T) {
runner := &runner{}
runner.safeRun(func() {
panic("Runner will catch this panic")
})
}
func TestOutputOnStart(t *testing.T) {
hitOutput := &HitOutput{}
hitOutput2 := &HitOutput{}
runner := &runner{}
runner.addOutput(hitOutput)
runner.addOutput(hitOutput2)
runner.outputOnStart()
if !hitOutput.onStart {
t.Error("hitOutput's OnStart has not been called")
}
if !hitOutput2.onStart {
t.Error("hitOutput2's OnStart has not been called")
}
}
func TestOutputOnEvent(t *testing.T) {
hitOutput := &HitOutput{}
hitOutput2 := &HitOutput{}
runner := &runner{}
runner.addOutput(hitOutput)
runner.addOutput(hitOutput2)
runner.outputOnEvent(nil)
if !hitOutput.onEvent {
t.Error("hitOutput's OnEvent has not been called")
}
if !hitOutput2.onEvent {
t.Error("hitOutput2's OnEvent has not been called")
}
}
func TestOutputOnStop(t *testing.T) {
hitOutput := &HitOutput{}
hitOutput2 := &HitOutput{}
runner := &runner{}
runner.addOutput(hitOutput)
runner.addOutput(hitOutput2)
runner.outputOnStop()
if !hitOutput.onStop {
t.Error("hitOutput's OnStop has not been called")
}
if !hitOutput2.onStop {
t.Error("hitOutput2's OnStop has not been called")
}
}
func TestLocalRunner(t *testing.T) {
taskA := &Task{
Weight: 10,
Fn: func() {
time.Sleep(time.Second)
},
Name: "TaskA",
}
tasks := []*Task{taskA}
runner := newLocalRunner(2, 2)
runner.setTasks(tasks)
go runner.start()
time.Sleep(4 * time.Second)
runner.stop()
}
func TestLoopCount(t *testing.T) {
taskA := &Task{
Weight: 10,
Fn: func() {
time.Sleep(time.Second)
},
Name: "TaskA",
}
tasks := []*Task{taskA}
runner := newLocalRunner(2, 2)
runner.loop = &Loop{loopCount: 4}
runner.setTasks(tasks)
go runner.start()
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
<-ticker.C
if !assert.Equal(t, runner.loop.loopCount, atomic.LoadInt64(&runner.loop.finishedCount)) {
t.Fail()
}
}
+316
View File
@@ -0,0 +1,316 @@
package boomer
import (
"time"
"github.com/httprunner/hrp/internal/json"
)
type transaction struct {
name string
success bool
elapsedTime int64
contentSize int64
}
type requestSuccess struct {
requestType string
name string
responseTime int64
responseLength int64
}
type requestFailure struct {
requestType string
name string
responseTime int64
errMsg string
}
type requestStats struct {
entries map[string]*statsEntry
errors map[string]*statsError
total *statsEntry
startTime int64
transactionChan chan *transaction
transactionPassed int64 // accumulated number of passed transactions
transactionFailed int64 // accumulated number of failed transactions
requestSuccessChan chan *requestSuccess
requestFailureChan chan *requestFailure
}
func newRequestStats() (stats *requestStats) {
entries := make(map[string]*statsEntry)
errors := make(map[string]*statsError)
stats = &requestStats{
entries: entries,
errors: errors,
}
stats.transactionChan = make(chan *transaction, 100)
stats.requestSuccessChan = make(chan *requestSuccess, 100)
stats.requestFailureChan = make(chan *requestFailure, 100)
stats.total = &statsEntry{
Name: "Total",
Method: "",
}
stats.total.reset()
return stats
}
func (s *requestStats) logTransaction(name string, success bool, responseTime int64, contentLength int64) {
if success {
s.transactionPassed++
} else {
s.transactionFailed++
s.get(name, "transaction").logFailures()
}
s.get(name, "transaction").log(responseTime, contentLength)
}
func (s *requestStats) logRequest(method, name string, responseTime int64, contentLength int64) {
s.total.log(responseTime, contentLength)
s.get(name, method).log(responseTime, contentLength)
}
func (s *requestStats) logError(method, name, err string) {
s.total.logFailures()
s.get(name, method).logFailures()
// store error in errors map
key := genMD5(method, name, err)
entry, ok := s.errors[key]
if !ok {
entry = &statsError{
name: name,
method: method,
errMsg: err,
}
s.errors[key] = entry
}
entry.occured()
}
func (s *requestStats) get(name string, method string) (entry *statsEntry) {
entry, ok := s.entries[name+method]
if !ok {
newEntry := &statsEntry{
Name: name,
Method: method,
NumReqsPerSec: make(map[int64]int64),
NumFailPerSec: make(map[int64]int64),
ResponseTimes: make(map[int64]int64),
}
s.entries[name+method] = newEntry
return newEntry
}
return entry
}
func (s *requestStats) clearAll() {
s.total = &statsEntry{
Name: "Total",
Method: "",
}
s.total.reset()
s.transactionPassed = 0
s.transactionFailed = 0
s.entries = make(map[string]*statsEntry)
s.errors = make(map[string]*statsError)
s.startTime = time.Now().Unix()
}
func (s *requestStats) serializeStats() []interface{} {
entries := make([]interface{}, 0, len(s.entries))
for _, v := range s.entries {
if !(v.NumRequests == 0 && v.NumFailures == 0) {
entries = append(entries, v.getStrippedReport())
}
}
return entries
}
func (s *requestStats) serializeErrors() map[string]map[string]interface{} {
errors := make(map[string]map[string]interface{})
for k, v := range s.errors {
errors[k] = v.toMap()
}
return errors
}
func (s *requestStats) collectReportData() map[string]interface{} {
data := make(map[string]interface{})
data["transactions"] = map[string]int64{
"passed": s.transactionPassed,
"failed": s.transactionFailed,
}
data["stats"] = s.serializeStats()
data["stats_total"] = s.total.serialize()
data["errors"] = s.serializeErrors()
s.errors = make(map[string]*statsError)
return data
}
// statsEntry represents a single stats entry (name and method)
type statsEntry struct {
// Name (URL) of this stats entry
Name string `json:"name"`
// Method (GET, POST, PUT, etc.)
Method string `json:"method"`
// The number of requests made
NumRequests int64 `json:"num_requests"`
// Number of failed request
NumFailures int64 `json:"num_failures"`
// Total sum of the response times
TotalResponseTime int64 `json:"total_response_time"`
// Minimum response time
MinResponseTime int64 `json:"min_response_time"`
// Maximum response time
MaxResponseTime int64 `json:"max_response_time"`
// A {second => request_count} dict that holds the number of requests made per second
NumReqsPerSec map[int64]int64 `json:"num_reqs_per_sec"`
// A (second => failure_count) dict that hold the number of failures per second
NumFailPerSec map[int64]int64 `json:"num_fail_per_sec"`
// A {response_time => count} dict that holds the response time distribution of all the requests
// The keys (the response time in ms) are rounded to store 1, 2, ... 9, 10, 20. .. 90,
// 100, 200 .. 900, 1000, 2000 ... 9000, in order to save memory.
// This dict is used to calculate the median and percentile response times.
ResponseTimes map[int64]int64 `json:"response_times"`
// The sum of the content length of all the requests for this entry
TotalContentLength int64 `json:"total_content_length"`
// Time of the first request for this entry
StartTime int64 `json:"start_time"`
// Time of the last request for this entry
LastRequestTimestamp int64 `json:"last_request_timestamp"`
// Boomer doesn't allow None response time for requests like locust.
// num_none_requests is added to keep compatible with locust.
NumNoneRequests int64 `json:"num_none_requests"`
}
func (s *statsEntry) reset() {
s.StartTime = time.Now().Unix()
s.NumRequests = 0
s.NumFailures = 0
s.TotalResponseTime = 0
s.ResponseTimes = make(map[int64]int64)
s.MinResponseTime = 0
s.MaxResponseTime = 0
s.LastRequestTimestamp = time.Now().Unix()
s.NumReqsPerSec = make(map[int64]int64)
s.NumFailPerSec = make(map[int64]int64)
s.TotalContentLength = 0
}
func (s *statsEntry) log(responseTime int64, contentLength int64) {
s.NumRequests++
s.logTimeOfRequest()
s.logResponseTime(responseTime)
s.TotalContentLength += contentLength
}
func (s *statsEntry) logTimeOfRequest() {
key := time.Now().Unix()
_, ok := s.NumReqsPerSec[key]
if !ok {
s.NumReqsPerSec[key] = 1
} else {
s.NumReqsPerSec[key]++
}
s.LastRequestTimestamp = key
}
func (s *statsEntry) logResponseTime(responseTime int64) {
s.TotalResponseTime += responseTime
if s.MinResponseTime == 0 {
s.MinResponseTime = responseTime
}
if responseTime < s.MinResponseTime {
s.MinResponseTime = responseTime
}
if responseTime > s.MaxResponseTime {
s.MaxResponseTime = responseTime
}
var roundedResponseTime int64
// to avoid too much data that has to be transferred to the master node when
// running in distributed mode, we save the response time rounded in a dict
// so that 147 becomes 150, 3432 becomes 3400 and 58760 becomes 59000
// see also locust's stats.py
if responseTime < 100 {
roundedResponseTime = responseTime
} else if responseTime < 1000 {
roundedResponseTime = int64(round(float64(responseTime), .5, -1))
} else if responseTime < 10000 {
roundedResponseTime = int64(round(float64(responseTime), .5, -2))
} else {
roundedResponseTime = int64(round(float64(responseTime), .5, -3))
}
_, ok := s.ResponseTimes[roundedResponseTime]
if !ok {
s.ResponseTimes[roundedResponseTime] = 1
} else {
s.ResponseTimes[roundedResponseTime]++
}
}
func (s *statsEntry) logFailures() {
s.NumFailures++
key := time.Now().Unix()
_, ok := s.NumFailPerSec[key]
if !ok {
s.NumFailPerSec[key] = 1
} else {
s.NumFailPerSec[key]++
}
}
func (s *statsEntry) serialize() map[string]interface{} {
var result map[string]interface{}
val, err := json.Marshal(s)
if err != nil {
return nil
}
err = json.Unmarshal(val, &result)
if err != nil {
return nil
}
return result
}
func (s *statsEntry) getStrippedReport() map[string]interface{} {
report := s.serialize()
s.reset()
return report
}
type statsError struct {
name string
method string
errMsg string
occurrences int64
}
func (err *statsError) occured() {
err.occurrences++
}
func (err *statsError) toMap() map[string]interface{} {
m := make(map[string]interface{})
m["method"] = err.method
m["name"] = err.name
m["error"] = err.errMsg
m["occurrences"] = err.occurrences
return m
}
+216
View File
@@ -0,0 +1,216 @@
package boomer
import (
"testing"
)
func TestLogRequest(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 2, 30)
newStats.logRequest("http", "success", 3, 40)
newStats.logRequest("http", "success", 2, 40)
newStats.logRequest("http", "success", 1, 20)
entry := newStats.get("success", "http")
if entry.NumRequests != 4 {
t.Error("numRequests is wrong, expected: 4, got:", entry.NumRequests)
}
if entry.MinResponseTime != 1 {
t.Error("minResponseTime is wrong, expected: 1, got:", entry.MinResponseTime)
}
if entry.MaxResponseTime != 3 {
t.Error("maxResponseTime is wrong, expected: 3, got:", entry.MaxResponseTime)
}
if entry.TotalResponseTime != 8 {
t.Error("totalResponseTime is wrong, expected: 8, got:", entry.TotalResponseTime)
}
if entry.TotalContentLength != 130 {
t.Error("totalContentLength is wrong, expected: 130, got:", entry.TotalContentLength)
}
// check newStats.total
if newStats.total.NumRequests != 4 {
t.Error("newStats.total.numRequests is wrong, expected: 4, got:", newStats.total.NumRequests)
}
if newStats.total.MinResponseTime != 1 {
t.Error("newStats.total.minResponseTime is wrong, expected: 1, got:", newStats.total.MinResponseTime)
}
if newStats.total.MaxResponseTime != 3 {
t.Error("newStats.total.maxResponseTime is wrong, expected: 3, got:", newStats.total.MaxResponseTime)
}
if newStats.total.TotalResponseTime != 8 {
t.Error("newStats.total.totalResponseTime is wrong, expected: 8, got:", newStats.total.TotalResponseTime)
}
if newStats.total.TotalContentLength != 130 {
t.Error("newStats.total.totalContentLength is wrong, expected: 130, got:", newStats.total.TotalContentLength)
}
}
func BenchmarkLogRequest(b *testing.B) {
newStats := newRequestStats()
for i := 0; i < b.N; i++ {
newStats.logRequest("http", "success", 2, 30)
}
}
func TestRoundedResponseTime(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 147, 1)
newStats.logRequest("http", "success", 3432, 1)
newStats.logRequest("http", "success", 58760, 1)
entry := newStats.get("success", "http")
responseTimes := entry.ResponseTimes
if len(responseTimes) != 3 {
t.Error("len(responseTimes) is wrong, expected: 3, got:", len(responseTimes))
}
if val, ok := responseTimes[150]; !ok || val != 1 {
t.Error("Rounded response time should be", 150)
}
if val, ok := responseTimes[3400]; !ok || val != 1 {
t.Error("Rounded response time should be", 3400)
}
if val, ok := responseTimes[59000]; !ok || val != 1 {
t.Error("Rounded response time should be", 59000)
}
}
func TestLogError(t *testing.T) {
newStats := newRequestStats()
newStats.logError("http", "failure", "500 error")
newStats.logError("http", "failure", "400 error")
newStats.logError("http", "failure", "400 error")
entry := newStats.get("failure", "http")
if entry.NumFailures != 3 {
t.Error("numFailures is wrong, expected: 3, got:", entry.NumFailures)
}
if newStats.total.NumFailures != 3 {
t.Error("newStats.total.numFailures is wrong, expected: 3, got:", newStats.total.NumFailures)
}
// md5("httpfailure500 error") = 547c38e4e4742c1c581f9e2809ba4f55
err500 := newStats.errors["547c38e4e4742c1c581f9e2809ba4f55"]
if err500.errMsg != "500 error" {
t.Error("Error message is wrong, expected: 500 error, got:", err500.errMsg)
}
if err500.occurrences != 1 {
t.Error("Error occurrences is wrong, expected: 1, got:", err500.occurrences)
}
// md5("httpfailure400 error") = f391c310401ad8e10e929f2ee1a614e4
err400 := newStats.errors["f391c310401ad8e10e929f2ee1a614e4"]
if err400.errMsg != "400 error" {
t.Error("Error message is wrong, expected: 400 error, got:", err400.errMsg)
}
if err400.occurrences != 2 {
t.Error("Error occurrences is wrong, expected: 2, got:", err400.occurrences)
}
}
func BenchmarkLogError(b *testing.B) {
newStats := newRequestStats()
for i := 0; i < b.N; i++ {
// LogError use md5 to calculate hash keys, it may slow down the only goroutine,
// which consumes both requestSuccessChannel and requestFailureChannel.
newStats.logError("http", "failure", "500 error")
}
}
func TestClearAll(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 1, 20)
newStats.clearAll()
if newStats.total.NumRequests != 0 {
t.Error("After clearAll(), newStats.total.numRequests is wrong, expected: 0, got:", newStats.total.NumRequests)
}
}
func TestClearAllByChannel(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 1, 20)
newStats.clearAll()
if newStats.total.NumRequests != 0 {
t.Error("After clearAll(), newStats.total.numRequests is wrong, expected: 0, got:", newStats.total.NumRequests)
}
}
func TestSerializeStats(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 1, 20)
serialized := newStats.serializeStats()
if len(serialized) != 1 {
t.Error("The length of serialized results is wrong, expected: 1, got:", len(serialized))
return
}
first := serialized[0]
entry, err := deserializeStatsEntry(first)
if err != nil {
t.Fail()
}
if entry.Name != "success" {
t.Error("The name is wrong, expected:", "success", "got:", entry.Name)
}
if entry.Method != "http" {
t.Error("The method is wrong, expected:", "http", "got:", entry.Method)
}
if entry.NumRequests != int64(1) {
t.Error("The num_requests is wrong, expected:", 1, "got:", entry.NumRequests)
}
if entry.NumFailures != int64(0) {
t.Error("The num_failures is wrong, expected:", 0, "got:", entry.NumFailures)
}
}
func TestSerializeErrors(t *testing.T) {
newStats := newRequestStats()
newStats.logError("http", "failure", "500 error")
newStats.logError("http", "failure", "400 error")
newStats.logError("http", "failure", "400 error")
serialized := newStats.serializeErrors()
if len(serialized) != 2 {
t.Error("The length of serialized results is wrong, expected: 2, got:", len(serialized))
return
}
for key, value := range serialized {
if key == "f391c310401ad8e10e929f2ee1a614e4" {
err := value["error"].(string)
if err != "400 error" {
t.Error("expected: 400 error, got:", err)
}
occurrences := value["occurrences"].(int64)
if occurrences != int64(2) {
t.Error("expected: 2, got:", occurrences)
}
}
}
}
func TestCollectReportData(t *testing.T) {
newStats := newRequestStats()
newStats.logRequest("http", "success", 2, 30)
newStats.logError("http", "failure", "500 error")
result := newStats.collectReportData()
if _, ok := result["stats"]; !ok {
t.Error("Key stats not found")
}
if _, ok := result["stats_total"]; !ok {
t.Error("Key stats not found")
}
if _, ok := result["errors"]; !ok {
t.Error("Key stats not found")
}
}
+13
View File
@@ -0,0 +1,13 @@
package boomer
// Task is like the "Locust object" in locust, the python version.
// When boomer receives a start message from master, it will spawn several goroutines to run Task.Fn.
// But users can keep some information in the python version, they can't do the same things in boomer.
// Because Task.Fn is a pure function.
type Task struct {
// The weight is used to distribute goroutines over multiple tasks.
Weight int
// Fn is called by the goroutines allocated to this task, in a loop.
Fn func()
Name string
}
+32
View File
@@ -0,0 +1,32 @@
// +build !windows
package boomer
import (
"syscall"
"github.com/rs/zerolog/log"
)
// set resource limit
// ulimit -n 10240
func SetUlimit(limit uint64) {
var rLimit syscall.Rlimit
err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
if err != nil {
log.Error().Err(err).Msg("get ulimit failed")
return
}
log.Info().Uint64("limit", rLimit.Cur).Msg("get current ulimit")
if rLimit.Cur >= limit {
return
}
rLimit.Cur = limit
log.Info().Uint64("limit", rLimit.Cur).Msg("set current ulimit")
err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
if err != nil {
log.Error().Err(err).Msg("set ulimit failed")
return
}
}
+12
View File
@@ -0,0 +1,12 @@
// +build windows
package boomer
import (
"github.com/rs/zerolog/log"
)
// set resource limit
func SetUlimit(limit uint64) {
log.Warn().Msg("windows does not support setting ulimit")
}
+77
View File
@@ -0,0 +1,77 @@
package boomer
import (
"crypto/md5"
"fmt"
"io"
"math"
"os"
"runtime/pprof"
"time"
"github.com/rs/zerolog/log"
)
func round(val float64, roundOn float64, places int) (newVal float64) {
var round float64
pow := math.Pow(10, float64(places))
digit := pow * val
_, div := math.Modf(digit)
if div >= roundOn {
round = math.Ceil(digit)
} else {
round = math.Floor(digit)
}
newVal = round / pow
return
}
// genMD5 returns the md5 hash of strings.
func genMD5(slice ...string) string {
h := md5.New()
for _, v := range slice {
io.WriteString(h, v)
}
return fmt.Sprintf("%x", h.Sum(nil))
}
// startMemoryProfile starts memory profiling and save the results in file.
func startMemoryProfile(file string, duration time.Duration) (err error) {
f, err := os.Create(file)
if err != nil {
return err
}
log.Info().Dur("duration", duration).Msg("Start memory profiling")
time.AfterFunc(duration, func() {
err := pprof.WriteHeapProfile(f)
if err != nil {
log.Error().Err(err).Msg("failed to write memory profile")
}
f.Close()
log.Info().Dur("duration", duration).Msg("Stop memory profiling")
})
return nil
}
// startCPUProfile starts cpu profiling and save the results in file.
func startCPUProfile(file string, duration time.Duration) (err error) {
f, err := os.Create(file)
if err != nil {
return err
}
log.Info().Dur("duration", duration).Msg("Start CPU profiling")
err = pprof.StartCPUProfile(f)
if err != nil {
f.Close()
return err
}
time.AfterFunc(duration, func() {
pprof.StopCPUProfile()
f.Close()
log.Info().Dur("duration", duration).Msg("Stop CPU profiling")
})
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package boomer
import (
"os"
"testing"
"time"
)
func TestRound(t *testing.T) {
if int(round(float64(147.5002), .5, -1)) != 150 {
t.Error("147.5002 should be rounded to 150")
}
if int(round(float64(3432.5002), .5, -2)) != 3400 {
t.Error("3432.5002 should be rounded to 3400")
}
roundOne := round(float64(58760.5002), .5, -3)
roundTwo := round(float64(58960.6003), .5, -3)
if roundOne != roundTwo {
t.Error("round(58760.5002) should be equal to round(58960.6003)")
}
roundOne = round(float64(58360.5002), .5, -3)
roundTwo = round(float64(58460.6003), .5, -3)
if roundOne != roundTwo {
t.Error("round(58360.5002) should be equal to round(58460.6003)")
}
roundOne = round(float64(58360), .5, -3)
roundTwo = round(float64(58460), .5, -3)
if roundOne != roundTwo {
t.Error("round(58360) should be equal to round(58460)")
}
}
func TestGenMD5(t *testing.T) {
hashValue := genMD5("Hello", "World!")
if hashValue != "06e0e6637d27b2622ab52022db713ce2" {
t.Error("Expected: 06e0e6637d27b2622ab52022db713ce2, Got: ", hashValue)
}
}
func TestStartMemoryProfile(t *testing.T) {
if _, err := os.Stat("mem.pprof"); os.IsExist(err) {
os.Remove("mem.pprof")
}
if err := startMemoryProfile("mem.pprof", 2*time.Second); err != nil {
t.Error("Error starting memory profiling")
}
time.Sleep(2100 * time.Millisecond)
if _, err := os.Stat("mem.pprof"); os.IsNotExist(err) {
t.Error("File mem.pprof is not generated")
} else {
os.Remove("mem.pprof")
}
}
func TestStartCPUProfile(t *testing.T) {
if _, err := os.Stat("cpu.pprof"); os.IsExist(err) {
os.Remove("cpu.pprof")
}
if err := startCPUProfile("cpu.pprof", 2*time.Second); err != nil {
t.Error("Error starting cpu profiling")
}
time.Sleep(2100 * time.Millisecond)
if _, err := os.Stat("cpu.pprof"); os.IsNotExist(err) {
t.Error("File cpu.pprof is not generated")
} else {
os.Remove("cpu.pprof")
}
}
+208
View File
@@ -0,0 +1,208 @@
package builtin
import (
"fmt"
"reflect"
"strings"
"github.com/stretchr/testify/assert"
)
var Assertions = map[string]func(t assert.TestingT, actual interface{}, expected interface{}, msgAndArgs ...interface{}) bool{
"eq": assert.EqualValues,
"equals": assert.EqualValues,
"equal": assert.EqualValues,
"lt": assert.Less,
"less_than": assert.Less,
"le": assert.LessOrEqual,
"less_or_equals": assert.LessOrEqual,
"gt": assert.Greater,
"greater_than": assert.Greater,
"ge": assert.GreaterOrEqual,
"greater_or_equals": assert.GreaterOrEqual,
"ne": assert.NotEqual,
"not_equal": assert.NotEqual,
"contains": assert.Contains,
"type_match": assert.IsType,
// custom assertions
"startswith": StartsWith,
"endswith": EndsWith,
"len_eq": EqualLength,
"length_equals": EqualLength,
"length_equal": EqualLength,
"len_lt": LessThanLength,
"count_lt": LessThanLength,
"length_less_than": LessThanLength,
"len_le": LessOrEqualsLength,
"count_le": LessOrEqualsLength,
"length_less_or_equals": LessOrEqualsLength,
"len_gt": GreaterThanLength,
"count_gt": GreaterThanLength,
"length_greater_than": GreaterThanLength,
"len_ge": GreaterOrEqualsLength,
"count_ge": GreaterOrEqualsLength,
"length_greater_or_equals": GreaterOrEqualsLength,
"contained_by": ContainedBy,
"str_eq": StringEqual,
"string_equals": StringEqual,
"regex_match": RegexMatch,
}
// StartsWith check if string starts with substring
func StartsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) {
return false
}
if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.HasPrefix(actualString, expectedString), msgAndArgs...)
}
// EndsWith check if string ends with substring
func EndsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) {
return false
}
if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.HasSuffix(actualString, expectedString), msgAndArgs...)
}
func EqualLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...)
}
return assert.Len(t, actual, length, msgAndArgs...)
}
func GreaterThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...)
}
if l <= length {
return assert.Fail(t, fmt.Sprintf("\"%s\" should be more than %d item(s), but has %d", actual, length, l), msgAndArgs...)
}
return true
}
func GreaterOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...)
}
if l < length {
return assert.Fail(t, fmt.Sprintf("\"%s\" should be no less than %d item(s), but has %d", actual, length, l), msgAndArgs...)
}
return true
}
func LessThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...)
}
if l >= length {
return assert.Fail(t, fmt.Sprintf("\"%s\" should be less than %d item(s), but has %d", actual, length, l), msgAndArgs...)
}
return true
}
func LessOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...)
}
if l > length {
return assert.Fail(t, fmt.Sprintf("\"%s\" should be no more than %d item(s), but has %d", actual, length, l), msgAndArgs...)
}
return true
}
// ContainedBy assert whether actual element contains expected element
func ContainedBy(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.Contains(t, expected, actual, msgAndArgs)
}
func StringEqual(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, msgAndArgs) {
return false
}
if !assert.IsType(t, "string", expected, msgAndArgs) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.EqualFold(actualString, expectedString), msgAndArgs)
}
func RegexMatch(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.Regexp(t, expected, actual, msgAndArgs)
}
func convertInt(value interface{}) (int, error) {
switch v := value.(type) {
case int:
return v, nil
case int8:
return int(v), nil
case int16:
return int(v), nil
case int32:
return int(v), nil
case int64:
return int(v), nil
case uint:
return int(v), nil
case uint8:
return int(v), nil
case uint16:
return int(v), nil
case uint32:
return int(v), nil
case uint64:
return int(v), nil
case float32:
return int(v), nil
case float64:
return int(v), nil
default:
return 0, fmt.Errorf("unsupported int convertion for %v(%T)", v, v)
}
}
// getLen try to get length of object.
// return (false, 0) if impossible.
func getLen(x interface{}) (ok bool, length int) {
v := reflect.ValueOf(x)
defer func() {
if e := recover(); e != nil {
ok = false
}
}()
return true, v.Len()
}
+191
View File
@@ -0,0 +1,191 @@
package builtin
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStartsWith(t *testing.T) {
testData := []struct {
raw string
expected string
}{
{"", ""},
{"a", "a"},
{"abc", "a"},
{"abc", "ab"},
}
for _, data := range testData {
if !assert.True(t, StartsWith(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestEndsWith(t *testing.T) {
testData := []struct {
raw string
expected string
}{
{"", ""},
{"a", "a"},
{"abc", "c"},
{"abc", "bc"},
}
for _, data := range testData {
if !assert.True(t, EndsWith(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestEqualLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 0},
{[]string{}, 0},
{map[string]interface{}{}, 0},
{"a", 1},
{[]string{"a"}, 1},
{map[string]interface{}{"a": 123}, 1},
}
for _, data := range testData {
if !assert.True(t, EqualLength(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestLessThanLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 1},
{[]string{}, 1},
{map[string]interface{}{}, 1},
{"a", 2},
{[]string{"a"}, 2},
{map[string]interface{}{"a": 123}, 2},
}
for _, data := range testData {
if !assert.True(t, LessThanLength(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestLessOrEqualsLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 1},
{[]string{}, 1},
{map[string]interface{}{"A": 111}, 1},
{"a", 1},
{[]string{"a"}, 2},
{map[string]interface{}{"a": 123}, 2},
}
for _, data := range testData {
if !assert.True(t, LessOrEqualsLength(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestGreaterThanLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"abcd", 3},
{[]string{"a", "b", "c"}, 2},
{map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2},
}
for _, data := range testData {
if !assert.True(t, GreaterThanLength(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestGreaterOrEqualsLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"abcd", 3},
{[]string{"w"}, 1},
{map[string]interface{}{"A": 111}, 1},
{"a", 1},
{[]string{"a", "b", "c"}, 2},
{map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2},
}
for _, data := range testData {
if !assert.True(t, GreaterOrEqualsLength(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestContainedBy(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"abcd", "abcdefg"},
{"a", []string{"a", "b", "c"}},
{"A", map[string]interface{}{"A": 111, "B": 222}},
}
for _, data := range testData {
if !assert.True(t, ContainedBy(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestStringEqual(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"abcd", "abcd"},
{"abcd", "ABCD"},
{"ABcd", "abCD"},
}
for _, data := range testData {
if !assert.True(t, StringEqual(t, data.raw, data.expected)) {
t.Fail()
}
}
}
func TestRegexMatch(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"it's starting...", regexp.MustCompile("start")},
{"it's not starting", "starting$"},
}
for _, data := range testData {
if !assert.True(t, RegexMatch(t, data.raw, data.expected)) {
t.Fail()
}
}
}
+260
View File
@@ -0,0 +1,260 @@
package builtin
import (
"bytes"
"crypto/md5"
"encoding/csv"
"encoding/hex"
builtinJSON "encoding/json"
"errors"
"fmt"
"math"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
"github.com/httprunner/hrp/internal/json"
)
var Functions = map[string]interface{}{
"get_timestamp": getTimestamp, // call without arguments
"sleep": sleep, // call with one argument
"gen_random_string": genRandomString, // call with one argument
"max": math.Max, // call with two arguments
"md5": MD5, // call with one argument
"parameterize": loadFromCSV,
"P": loadFromCSV,
}
func init() {
rand.Seed(time.Now().UnixNano())
}
func getTimestamp() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func sleep(nSecs int) {
time.Sleep(time.Duration(nSecs) * time.Second)
}
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
func genRandomString(n int) string {
lettersLen := len(letters)
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(lettersLen)]
}
return string(b)
}
func MD5(str string) string {
hasher := md5.New()
hasher.Write([]byte(str))
return hex.EncodeToString(hasher.Sum(nil))
}
func loadFromCSV(path string) []map[string]interface{} {
path, err := filepath.Abs(path)
if err != nil {
log.Error().Str("path", path).Err(err).Msg("convert absolute path failed")
panic(err)
}
log.Info().Str("path", path).Msg("load csv file")
file, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("load csv file failed")
panic(err)
}
r := csv.NewReader(strings.NewReader(string(file)))
content, err := r.ReadAll()
if err != nil {
log.Error().Err(err).Msg("parse csv file failed")
panic(err)
}
var result []map[string]interface{}
for i := 1; i < len(content); i++ {
row := make(map[string]interface{})
for j := 0; j < len(content[i]); j++ {
row[content[0][j]] = content[i][j]
}
result = append(result, row)
}
return result
}
func Dump2JSON(data interface{}, path string) error {
path, err := filepath.Abs(path)
if err != nil {
log.Error().Err(err).Msg("convert absolute path failed")
return err
}
log.Info().Str("path", path).Msg("dump data to json")
file, _ := json.MarshalIndent(data, "", " ")
err = os.WriteFile(path, file, 0644)
if err != nil {
log.Error().Err(err).Msg("dump json path failed")
return err
}
return nil
}
func Dump2YAML(data interface{}, path string) error {
path, err := filepath.Abs(path)
if err != nil {
log.Error().Err(err).Msg("convert absolute path failed")
return err
}
log.Info().Str("path", path).Msg("dump data to yaml")
// init yaml encoder
buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(4)
// encode
err = encoder.Encode(data)
if err != nil {
return err
}
err = os.WriteFile(path, buffer.Bytes(), 0644)
if err != nil {
log.Error().Err(err).Msg("dump yaml path failed")
return err
}
return nil
}
func FormatResponse(raw interface{}) interface{} {
formattedResponse := make(map[string]interface{})
for key, value := range raw.(map[string]interface{}) {
// convert value to json
if key == "body" {
b, _ := json.MarshalIndent(&value, "", " ")
value = string(b)
}
formattedResponse[key] = value
}
return formattedResponse
}
func ExecCommand(cmd *exec.Cmd, cwd string) error {
log.Info().Str("cmd", cmd.String()).Str("cwd", cwd).Msg("exec command")
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(output))
if err != nil {
log.Error().Err(err).Str("output", out).Msg("exec command failed")
} else if len(out) != 0 {
log.Info().Str("output", out).Msg("exec command success")
}
return err
}
func CreateFolder(folderPath string) error {
log.Info().Str("path", folderPath).Msg("create folder")
err := os.MkdirAll(folderPath, os.ModePerm)
if err != nil {
log.Error().Err(err).Msg("create folder failed")
return err
}
return nil
}
func CreateFile(filePath string, data string) error {
log.Info().Str("path", filePath).Msg("create file")
err := os.WriteFile(filePath, []byte(data), 0o644)
if err != nil {
log.Error().Err(err).Msg("create file failed")
return err
}
return nil
}
// isFilePathExists returns true if path exists, whether path is file or dir
func isPathExists(path string) bool {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
return true
}
// isFilePathExists returns true if path exists and path is file
func isFilePathExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
// path not exists
return false
}
// path exists
if info.IsDir() {
// path is dir, not file
return false
}
return true
}
func EnsureFolderExists(folderPath string) error {
if !isPathExists(folderPath) {
err := CreateFolder(folderPath)
return err
} else if isFilePathExists(folderPath) {
return fmt.Errorf("path %v should be directory", folderPath)
}
return nil
}
func Contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func GetRandomNumber(min, max int) int {
if min > max {
return 0
}
r := rand.Intn(max - min + 1)
return min + r
}
func Interface2Float64(i interface{}) (float64, error) {
switch i.(type) {
case int:
return float64(i.(int)), nil
case int32:
return float64(i.(int32)), nil
case int64:
return float64(i.(int64)), nil
case float32:
return float64(i.(float32)), nil
case float64:
return i.(float64), nil
case string:
intVar, err := strconv.Atoi(i.(string))
if err != nil {
return 0, err
}
return float64(intVar), err
}
// json.Number
value, ok := i.(builtinJSON.Number)
if ok {
return value.Float64()
}
return 0, errors.New("failed to convert interface to float64")
}
+76
View File
@@ -0,0 +1,76 @@
package ga
import (
"fmt"
"net/http"
"net/url"
"reflect"
"time"
)
const (
gaAPIDebugURL = "https://www.google-analytics.com/debug/collect" // used for debug
gaAPIURL = "https://www.google-analytics.com/collect"
)
type GAClient struct {
TrackingID string `form:"tid"` // Tracking ID / Property ID, XX-XXXXXXX-X
ClientID string `form:"cid"` // Anonymous Client ID
Version string `form:"v"` // Version
httpClient *http.Client // http client session
}
// NewGAClient creates a new GAClient object with the trackingID and clientID.
func NewGAClient(trackingID, clientID string) *GAClient {
return &GAClient{
TrackingID: trackingID,
ClientID: clientID,
Version: "1", // constant v1
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}
// SendEvent sends one event to Google Analytics
func (g *GAClient) SendEvent(e IEvent) error {
var data url.Values
if event, ok := e.(UserTimingTracking); ok {
event.duration = time.Since(event.startTime)
data = event.ToUrlValues()
} else {
data = e.ToUrlValues()
}
// append common params
data.Add("v", g.Version)
data.Add("tid", g.TrackingID)
data.Add("cid", g.ClientID)
resp, err := g.httpClient.PostForm(gaAPIURL, data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("response status: %d", resp.StatusCode)
}
return nil
}
func structToUrlValues(i interface{}) (values url.Values) {
values = url.Values{}
iVal := reflect.ValueOf(i)
for i := 0; i < iVal.NumField(); i++ {
formTagName := iVal.Type().Field(i).Tag.Get("form")
if formTagName == "" {
continue
}
if iVal.Field(i).IsZero() {
continue
}
values.Set(formTagName, fmt.Sprint(iVal.Field(i)))
}
return
}
+30
View File
@@ -0,0 +1,30 @@
package ga
import (
"testing"
)
func TestSendEvents(t *testing.T) {
event := EventTracking{
Category: "unittest",
Action: "SendEvents",
Value: 123,
}
err := gaClient.SendEvent(event)
if err != nil {
t.Fatal(err)
}
}
func TestStructToUrlValues(t *testing.T) {
event := EventTracking{
Category: "unittest",
Action: "convert",
Label: "v0.3.0",
Value: 123,
}
val := structToUrlValues(event)
if val.Encode() != "ea=convert&ec=unittest&el=v0.3.0&ev=123" {
t.Fail()
}
}
+71
View File
@@ -0,0 +1,71 @@
package ga
import (
"fmt"
"net/url"
"time"
"github.com/httprunner/hrp/internal/version"
)
type IEvent interface {
ToUrlValues() url.Values
}
type EventTracking struct {
HitType string `form:"t"` // Event hit type = event
Category string `form:"ec"` // Required. Event Category.
Action string `form:"ea"` // Required. Event Action.
Label string `form:"el"` // Optional. Event label, used as version.
Value int `form:"ev"` // Optional. Event value, must be non-negative integer
}
func (e EventTracking) StartTiming(variable string) UserTimingTracking {
return UserTimingTracking{
HitType: "timing",
Category: e.Category,
Variable: variable,
Label: e.Label,
startTime: time.Now(), // starts the timer
}
}
func (e EventTracking) ToUrlValues() url.Values {
e.HitType = "event"
e.Label = version.VERSION
return structToUrlValues(e)
}
type UserTimingTracking struct {
HitType string `form:"t"` // Timing hit type
Category string `form:"utc"` // Required. user timing category. e.g. jsonLoader
Variable string `form:"utv"` // Required. timing variable. e.g. load
Duration string `form:"utt"` // Required. time took duration.
Label string `form:"utl"` // Optional. user timing label. e.g jQuery
startTime time.Time
duration time.Duration // time took duration
}
func (e UserTimingTracking) ToUrlValues() url.Values {
e.HitType = "timing"
e.Label = version.VERSION
e.Duration = fmt.Sprintf("%d", int64(e.duration.Seconds()*1000))
return structToUrlValues(e)
}
type Exception struct {
HitType string `form:"t"` // Hit Type = exception
Description string `form:"exd"` // exception description. i.e. IOException
IsFatal string `form:"exf"` // if the exception was fatal
isFatal bool
}
func (e Exception) ToUrlValues() url.Values {
e.HitType = "exception"
if e.isFatal {
e.IsFatal = "1"
} else {
e.IsFatal = "0"
}
return structToUrlValues(e)
}
+25
View File
@@ -0,0 +1,25 @@
package ga
import (
"github.com/denisbrodbeck/machineid"
"github.com/google/uuid"
)
const (
trackingID = "UA-114587036-1" // Tracking ID for Google Analytics
)
var gaClient *GAClient
func init() {
clientID, err := machineid.ProtectedID("hrp")
if err != nil {
nodeUUID, _ := uuid.NewUUID()
clientID = nodeUUID.String()
}
gaClient = NewGAClient(trackingID, clientID)
}
func SendEvent(e IEvent) error {
return gaClient.SendEvent(e)
}
+9
View File
@@ -0,0 +1,9 @@
# har2case
Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner and HttpRunner+.
## Install
## Quick Start
## Examples
+352
View File
@@ -0,0 +1,352 @@
package har2case
import (
"encoding/base64"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp"
"github.com/httprunner/hrp/internal/builtin"
"github.com/httprunner/hrp/internal/ga"
"github.com/httprunner/hrp/internal/json"
)
const (
suffixJSON = ".json"
suffixYAML = ".yaml"
)
func NewHAR(path string) *har {
return &har{
path: path,
}
}
type har struct {
path string
filterStr string
excludeStr string
outputDir string
}
func (h *har) SetOutputDir(dir string) {
h.outputDir = dir
}
func (h *har) GenJSON() (jsonPath string, err error) {
event := ga.EventTracking{
Category: "ConvertTests",
Action: "hrp har2case --to-json",
}
// report start event
go ga.SendEvent(event)
// report running timing event
defer ga.SendEvent(event.StartTiming("execution"))
tCase, err := h.makeTestCase()
if err != nil {
return "", err
}
jsonPath = h.genOutputPath(suffixJSON)
err = builtin.Dump2JSON(tCase, jsonPath)
return
}
func (h *har) GenYAML() (yamlPath string, err error) {
event := ga.EventTracking{
Category: "ConvertTests",
Action: "hrp har2case --to-yaml",
}
// report start event
go ga.SendEvent(event)
// report running timing event
defer ga.SendEvent(event.StartTiming("execution"))
tCase, err := h.makeTestCase()
if err != nil {
return "", err
}
yamlPath = h.genOutputPath(suffixYAML)
err = builtin.Dump2YAML(tCase, yamlPath)
return
}
func (h *har) makeTestCase() (*hrp.TCase, error) {
teststeps, err := h.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: h.prepareConfig(),
TestSteps: teststeps,
}
return tCase, nil
}
func (h *har) load() (*Har, error) {
fp, err := os.Open(h.path)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
data, err := io.ReadAll(fp)
fp.Close()
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
har := &Har{}
err = json.Unmarshal(data, har)
if err != nil {
return nil, fmt.Errorf("json.Unmarshal error: %w", err)
}
return har, nil
}
func (h *har) prepareConfig() *hrp.TConfig {
return hrp.NewConfig("testcase description").
SetVerifySSL(false)
}
func (h *har) prepareTestSteps() ([]*hrp.TStep, error) {
har, err := h.load()
if err != nil {
return nil, err
}
var steps []*hrp.TStep
for _, entry := range har.Log.Entries {
step, err := h.prepareTestStep(&entry)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) {
log.Info().
Str("method", entry.Request.Method).
Str("url", entry.Request.URL).
Msg("convert teststep")
step := &tStep{
TStep: hrp.TStep{
Request: &hrp.Request{},
Validators: make([]interface{}, 0),
},
}
if err := step.makeRequestMethod(entry); err != nil {
return nil, err
}
if err := step.makeRequestURL(entry); err != nil {
return nil, err
}
if err := step.makeRequestParams(entry); err != nil {
return nil, err
}
if err := step.makeRequestCookies(entry); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(entry); err != nil {
return nil, err
}
if err := step.makeRequestBody(entry); err != nil {
return nil, err
}
if err := step.makeValidate(entry); err != nil {
return nil, err
}
return &step.TStep, nil
}
type tStep struct {
hrp.TStep
}
func (s *tStep) makeRequestMethod(entry *Entry) error {
s.Request.Method = entry.Request.Method
return nil
}
func (s *tStep) makeRequestURL(entry *Entry) error {
u, err := url.Parse(entry.Request.URL)
if err != nil {
log.Error().Err(err).Msg("make request url failed")
return err
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Hostname()+u.Path)
return nil
}
func (s *tStep) makeRequestParams(entry *Entry) error {
s.Request.Params = make(map[string]interface{})
for _, param := range entry.Request.QueryString {
s.Request.Params[param.Name] = param.Value
}
return nil
}
func (s *tStep) makeRequestCookies(entry *Entry) error {
s.Request.Cookies = make(map[string]string)
for _, cookie := range entry.Request.Cookies {
s.Request.Cookies[cookie.Name] = cookie.Value
}
return nil
}
func (s *tStep) makeRequestHeaders(entry *Entry) error {
s.Request.Headers = make(map[string]string)
for _, header := range entry.Request.Headers {
if strings.EqualFold(header.Name, "cookie") {
continue
}
s.Request.Headers[header.Name] = header.Value
}
return nil
}
func (s *tStep) makeRequestBody(entry *Entry) error {
mimeType := entry.Request.PostData.MimeType
if mimeType == "" {
// GET/HEAD/DELETE without body
return nil
}
// POST/PUT with body
if strings.HasPrefix(mimeType, "application/json") {
// post json
var body interface{}
err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body)
if err != nil {
log.Error().Err(err).Msg("make request body failed")
return err
}
s.Request.Body = body
} else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") {
// post form
var paramsList []string
for _, param := range entry.Request.PostData.Params {
paramsList = append(paramsList, fmt.Sprintf("%s=%s", param.Name, param.Value))
}
s.Request.Body = strings.Join(paramsList, "&")
} else if strings.HasPrefix(mimeType, "text/plain") {
// post raw data
s.Request.Body = entry.Request.PostData.Text
} else {
// TODO
log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType)
}
return nil
}
func (s *tStep) makeValidate(entry *Entry) error {
// make validator for response status code
s.Validators = append(s.Validators, hrp.Validator{
Check: "status_code",
Assert: "equals",
Expect: entry.Response.Status,
Message: "assert response status code",
})
// make validators for response headers
for _, header := range entry.Response.Headers {
// assert Content-Type
if strings.EqualFold(header.Name, "Content-Type") {
s.Validators = append(s.Validators, hrp.Validator{
Check: "headers.\"Content-Type\"",
Assert: "equals",
Expect: header.Value,
Message: "assert response header Content-Type",
})
}
}
// make validators for response body
respBody := entry.Response.Content
if respBody.Text == "" {
// response body is empty
return nil
}
if strings.HasPrefix(respBody.MimeType, "application/json") {
var data []byte
var err error
// response body is json
if respBody.Encoding == "base64" {
// decode base64 text
data, err = base64.StdEncoding.DecodeString(respBody.Text)
if err != nil {
return errors.Wrap(err, "decode base64 error")
}
} else if respBody.Encoding == "" {
// no encoding
data = []byte(respBody.Text)
} else {
// other encoding type
return nil
}
// convert to json
var body interface{}
if err = json.Unmarshal(data, &body); err != nil {
return errors.Wrap(err, "json.Unmarshal body error")
}
jsonBody, ok := body.(map[string]interface{})
if !ok {
return fmt.Errorf("response body is not json, not matched with MimeType")
}
// response body is json
keys := make([]string, 0, len(jsonBody))
for k := range jsonBody {
keys = append(keys, k)
}
// sort map keys to keep validators in stable order
sort.Strings(keys)
for _, key := range keys {
value := jsonBody[key]
switch v := value.(type) {
case map[string]interface{}:
continue
case []interface{}:
continue
default:
s.Validators = append(s.Validators, hrp.Validator{
Check: fmt.Sprintf("body.%s", key),
Assert: "equals",
Expect: v,
Message: fmt.Sprintf("assert response body %s", key),
})
}
}
}
return nil
}
func (h *har) genOutputPath(suffix string) string {
file := getFilenameWithoutExtension(h.path) + suffix
if h.outputDir != "" {
return filepath.Join(h.outputDir, file)
} else {
return filepath.Join(filepath.Dir(h.path), file)
}
}
func getFilenameWithoutExtension(path string) string {
base := filepath.Base(path)
ext := filepath.Ext(base)
return base[0 : len(base)-len(ext)]
}
+122
View File
@@ -0,0 +1,122 @@
package har2case
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/httprunner/hrp"
)
var (
harPath = "../../examples/har/demo.har"
harPath2 = "../../examples/har/postman-echo.har"
)
func TestGenJSON(t *testing.T) {
jsonPath, err := NewHAR(harPath).GenJSON()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.NotEmpty(t, jsonPath) {
t.Fail()
}
}
func TestGenYAML(t *testing.T) {
yamlPath, err := NewHAR(harPath2).GenYAML()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.NotEmpty(t, yamlPath) {
t.Fail()
}
}
func TestLoadHAR(t *testing.T) {
har := NewHAR(harPath)
h, err := har.load()
if !assert.NoError(t, err) {
t.Fail()
}
if !assert.Equal(t, "GET", h.Log.Entries[0].Request.Method) {
t.Fail()
}
if !assert.Equal(t, "POST", h.Log.Entries[1].Request.Method) {
t.Fail()
}
}
func TestMakeTestCase(t *testing.T) {
har := NewHAR(harPath)
tCase, err := har.makeTestCase()
if !assert.NoError(t, err) {
t.Fail()
}
// make request method
if !assert.EqualValues(t, "GET", tCase.TestSteps[0].Request.Method) {
t.Fail()
}
if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) {
t.Fail()
}
// make request url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) {
t.Fail()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) {
t.Fail()
}
// make request params
if !assert.Equal(t, "HDnY8", tCase.TestSteps[0].Request.Params["foo1"]) {
t.Fail()
}
// make request cookies
if !assert.NotEmpty(t, tCase.TestSteps[1].Request.Cookies["sails.sid"]) {
t.Fail()
}
// make request headers
if !assert.Equal(t, "HttpRunnerPlus", tCase.TestSteps[0].Request.Headers["User-Agent"]) {
t.Fail()
}
if !assert.Equal(t, "postman-echo.com", tCase.TestSteps[0].Request.Headers["Host"]) {
t.Fail()
}
// make request data
if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) {
t.Fail()
}
if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.TestSteps[1].Request.Body) {
t.Fail()
}
if !assert.Equal(t, "foo1=HDnY8&foo2=12.3", tCase.TestSteps[2].Request.Body) {
t.Fail()
}
// make validators
validator, ok := tCase.TestSteps[0].Validators[0].(hrp.Validator)
if !ok || !assert.Equal(t, "status_code", validator.Check) {
t.Fail()
}
validator, ok = tCase.TestSteps[0].Validators[1].(hrp.Validator)
if !ok || !assert.Equal(t, "headers.\"Content-Type\"", validator.Check) {
t.Fail()
}
validator, ok = tCase.TestSteps[0].Validators[2].(hrp.Validator)
if !ok || !assert.Equal(t, "body.url", validator.Check) {
t.Fail()
}
}
func TestGetFilenameWithoutExtension(t *testing.T) {
filename := getFilenameWithoutExtension("examples/har/postman-echo.har")
if !assert.Equal(t, "postman-echo", filename) {
t.Fail()
}
}
+340
View File
@@ -0,0 +1,340 @@
package har2case
import "time"
/*
HTTP Archive (HAR) format
https://w3c.github.io/web-performance/specs/HAR/Overview.html
this file is copied from https://github.com/mrichman/hargo/blob/master/types.go
*/
// Har is a container type for deserialization
type Har struct {
Log Log `json:"log"`
}
// Log represents the root of the exported data. This object MUST be present and its name MUST be "log".
type Log struct {
// The object contains the following name/value pairs:
// Required. Version number of the format.
Version string `json:"version"`
// Required. An object of type creator that contains the name and version
// information of the log creator application.
Creator Creator `json:"creator"`
// Optional. An object of type browser that contains the name and version
// information of the user agent.
Browser Browser `json:"browser"`
// Optional. An array of objects of type page, each representing one exported
// (tracked) page. Leave out this field if the application does not support
// grouping by pages.
Pages []Page `json:"pages,omitempty"`
// Required. An array of objects of type entry, each representing one
// exported (tracked) HTTP request.
Entries []Entry `json:"entries"`
// Optional. A comment provided by the user or the application. Sorting
// entries by startedDateTime (starting from the oldest) is preferred way how
// to export data since it can make importing faster. However the reader
// application should always make sure the array is sorted (if required for
// the import).
Comment string `json:"comment"`
}
// Creator contains information about the log creator application
type Creator struct {
// Required. The name of the application that created the log.
Name string `json:"name"`
// Required. The version number of the application that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Browser that created the log
type Browser struct {
// Required. The name of the browser that created the log.
Name string `json:"name"`
// Required. The version number of the browser that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the browser.
Comment string `json:"comment"`
}
// Page object for every exported web page and one <entry> object for every HTTP request.
// In case when an HTTP trace tool isn't able to group requests by a page,
// the <pages> object is empty and individual requests doesn't have a parent page.
type Page struct {
/* There is one <page> object for every exported web page and one <entry>
object for every HTTP request. In case when an HTTP trace tool isn't able to
group requests by a page, the <pages> object is empty and individual
requests doesn't have a parent page.
*/
// Date and time stamp for the beginning of the page load
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00).
StartedDateTime string `json:"startedDateTime"`
// Unique identifier of a page within the . Entries use it to refer the parent page.
ID string `json:"id"`
// Page title.
Title string `json:"title"`
// Detailed timing info about page load.
PageTiming PageTiming `json:"pageTiming"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTiming describes timings for various events (states) fired during the page load.
// All times are specified in milliseconds. If a time info is not available appropriate field is set to -1.
type PageTiming struct {
// Content of the page loaded. Number of milliseconds since page load started
// (page.startedDateTime). Use -1 if the timing does not apply to the current
// request.
// Depeding on the browser, onContentLoad property represents DOMContentLoad
// event or document.readyState == interactive.
OnContentLoad int `json:"onContentLoad"`
// Page is loaded (onLoad event fired). Number of milliseconds since page
// load started (page.startedDateTime). Use -1 if the timing does not apply
// to the current request.
OnLoad int `json:"onLoad"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Entry is a unique, optional Reference to the parent page.
// Leave out this field if the application does not support grouping by pages.
type Entry struct {
Pageref string `json:"pageref,omitempty"`
// Date and time stamp of the request start
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD).
StartedDateTime string `json:"startedDateTime"`
// Total elapsed time of the request in milliseconds. This is the sum of all
// timings available in the timings object (i.e. not including -1 values) .
Time float32 `json:"time"`
// Detailed info about the request.
Request Request `json:"request"`
// Detailed info about the response.
Response Response `json:"response"`
// Info about cache usage.
Cache Cache `json:"cache"`
// Detailed timing info about request/response round trip.
PageTimings PageTimings `json:"pageTimings"`
// optional (new in 1.2) IP address of the server that was connected
// (result of DNS resolution).
ServerIPAddress string `json:"serverIPAddress,omitempty"`
// optional (new in 1.2) Unique ID of the parent TCP/IP connection, can be
// the client port number. Note that a port number doesn't have to be unique
// identifier in cases where the port is shared for more connections. If the
// port isn't available for the application, any other unique connection ID
// can be used instead (e.g. connection index). Leave out this field if the
// application doesn't support this info.
Connection string `json:"connection,omitempty"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Request contains detailed info about performed request.
type Request struct {
// Request method (GET, POST, ...).
Method string `json:"method"`
// Absolute URL of the request (fragments are not included).
URL string `json:"url"`
// Request HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// List of query parameter objects.
QueryString []NVP `json:"queryString"`
// Posted data.
PostData PostData `json:"postData"`
// Total number of bytes from the start of the HTTP request message until
// (and including) the double CRLF before the body. Set to -1 if the info
// is not available.
HeaderSize int `json:"headerSize"`
// Size of the request body (POST data payload) in bytes. Set to -1 if the
// info is not available.
BodySize int `json:"bodySize"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Response contains detailed info about the response.
type Response struct {
// Response status.
Status int `json:"status"`
// Response status description.
StatusText string `json:"statusText"`
// Response HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// Details about the response body.
Content Content `json:"content"`
// Redirection target URL from the Location response header.
RedirectURL string `json:"redirectURL"`
// Total number of bytes from the start of the HTTP response message until
// (and including) the double CRLF before the body. Set to -1 if the info is
// not available.
// The size of received response-headers is computed only from headers that
// are really received from the server. Additional headers appended by the
// browser are not included in this number, but they appear in the list of
// header objects.
HeadersSize int `json:"headersSize"`
// Size of the received response body in bytes. Set to zero in case of
// responses coming from the cache (304). Set to -1 if the info is not
// available.
BodySize int `json:"bodySize"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cookie contains list of all cookies (used in <request> and <response> objects).
type Cookie struct {
// The name of the cookie.
Name string `json:"name"`
// The cookie value.
Value string `json:"value"`
// optional The path pertaining to the cookie.
Path string `json:"path,omitempty"`
// optional The host of the cookie.
Domain string `json:"domain,omitempty"`
// optional Cookie expiration time.
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.123+02:00).
Expires string `json:"expires,omitempty"`
// optional Set to true if the cookie is HTTP only, false otherwise.
HTTPOnly bool `json:"httpOnly,omitempty"`
// optional (new in 1.2) True if the cookie was transmitted over ssl, false
// otherwise.
Secure bool `json:"secure,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment bool `json:"comment,omitempty"`
}
// NVP is simply a name/value pair with a comment
type NVP struct {
Name string `json:"name"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
// PostData describes posted data, if any (embedded in <request> object).
type PostData struct {
// Mime type of posted data.
MimeType string `json:"mimeType"`
// List of posted parameters (in case of URL encoded parameters).
Params []PostParam `json:"params"`
// Plain text posted data
Text string `json:"text"`
// optional (new in 1.2) A comment provided by the user or the
// application.
Comment string `json:"comment,omitempty"`
}
// PostParam is a list of posted parameters, if any (embedded in <postData> object).
type PostParam struct {
// name of a posted parameter.
Name string `json:"name"`
// optional value of a posted parameter or content of a posted file.
Value string `json:"value,omitempty"`
// optional name of a posted file.
FileName string `json:"fileName,omitempty"`
// optional content type of a posted file.
ContentType string `json:"contentType,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Content describes details about response content (embedded in <response> object).
type Content struct {
// Length of the returned content in bytes. Should be equal to
// response.bodySize if there is no compression and bigger when the content
// has been compressed.
Size int `json:"size"`
// optional Number of bytes saved. Leave out this field if the information
// is not available.
Compression int `json:"compression,omitempty"`
// MIME type of the response text (value of the Content-Type response
// header). The charset attribute of the MIME type is included (if
// available).
MimeType string `json:"mimeType"`
// optional Response body sent from the server or loaded from the browser
// cache. This field is populated with textual content only. The text field
// is either HTTP decoded text or a encoded (e.g. "base64") representation of
// the response body. Leave out this field if the information is not
// available.
Text string `json:"text,omitempty"`
// optional (new in 1.2) Encoding used for response text field e.g
// "base64". Leave out this field if the text field is HTTP decoded
// (decompressed & unchunked), than trans-coded from its original character
// set into UTF-8.
Encoding string `json:"encoding,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cache contains info about a request coming from browser cache.
type Cache struct {
// optional State of a cache entry before the request. Leave out this field
// if the information is not available.
BeforeRequest CacheObject `json:"beforeRequest,omitempty"`
// optional State of a cache entry after the request. Leave out this field if
// the information is not available.
AfterRequest CacheObject `json:"afterRequest,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// CacheObject is used by both beforeRequest and afterRequest
type CacheObject struct {
// optional - Expiration time of the cache entry.
Expires string `json:"expires,omitempty"`
// The last time the cache entry was opened.
LastAccess string `json:"lastAccess"`
// Etag
ETag string `json:"eTag"`
// The number of times the cache entry has been opened.
HitCount int `json:"hitCount"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTimings describes various phases within request-response round trip.
// All times are specified in milliseconds.
type PageTimings struct {
Blocked int `json:"blocked,omitempty"`
// optional - Time spent in a queue waiting for a network connection. Use -1
// if the timing does not apply to the current request.
DNS int `json:"dns,omitempty"`
// optional - DNS resolution time. The time required to resolve a host name.
// Use -1 if the timing does not apply to the current request.
Connect int `json:"connect,omitempty"`
// optional - Time required to create TCP connection. Use -1 if the timing
// does not apply to the current request.
Send int `json:"send"`
// Time required to send HTTP request to the server.
Wait int `json:"wait"`
// Waiting for a response from the server.
Receive int `json:"receive"`
// Time required to read entire response from the server (or cache).
Ssl int `json:"ssl,omitempty"`
// optional (new in 1.2) - Time required for SSL/TLS negotiation. If this
// field is defined then the time is also included in the connect field (to
// ensure backward compatibility with HAR 1.1). Use -1 if the timing does not
// apply to the current request.
Comment string `json:"comment,omitempty"`
// optional (new in 1.2) - A comment provided by the user or the application.
}
// TestResult contains results for an individual HTTP request
type TestResult struct {
URL string `json:"url"`
Status int `json:"status"` // 200, 500, etc.
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Latency int `json:"latency"` // milliseconds
Method string `json:"method"`
HarFile string `json:"harfile"`
}
+16
View File
@@ -0,0 +1,16 @@
package json
import (
jsoniter "github.com/json-iterator/go"
)
// replace with third-party json library to improve performance
var json = jsoniter.ConfigCompatibleWithStandardLibrary
var (
Marshal = json.Marshal
MarshalIndent = json.MarshalIndent
Unmarshal = json.Unmarshal
NewDecoder = json.NewDecoder
Get = json.Get
)
+359
View File
@@ -0,0 +1,359 @@
<head>
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TestReport</title>
<style>
body {
background-color: #f2f2f2;
color: #333;
margin: 0 auto;
width: 960px;
}
#summary {
width: 960px;
margin-bottom: 20px;
}
#summary th {
background-color: skyblue;
padding: 5px 12px;
}
#summary td {
background-color: lightblue;
text-align: center;
padding: 4px 8px;
}
.details {
width: 960px;
margin-bottom: 20px;
}
.details th {
background-color: skyblue;
padding: 5px 12px;
}
.details tr .passed {
background-color: lightgreen;
}
.details tr .failed {
background-color: red;
}
.details tr .unchecked {
background-color: gray;
}
.details td {
background-color: lightblue;
padding: 5px 12px;
}
.details .detail {
background-color: lightgrey;
font-size: smaller;
padding: 5px 10px;
line-height: 20px;
text-align: left;
}
.details .success {
background-color: greenyellow;
}
.details .error {
background-color: red;
}
.details .failure {
background-color: salmon;
}
.details .skipped {
background-color: gray;
}
.button {
font-size: 1em;
padding: 6px;
width: 4em;
text-align: center;
background-color: #06d85f;
border-radius: 20px/50px;
cursor: pointer;
transition: all 0.3s ease-out;
}
a.button {
color: gray;
text-decoration: none;
display: inline-block;
}
.button:hover {
background: #2cffbd;
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
transition: opacity 500ms;
visibility: hidden;
opacity: 0;
line-height: 25px;
}
.overlay:target {
visibility: visible;
opacity: 1;
}
.popup {
margin: 70px auto;
padding: 20px;
background: #fff;
border-radius: 10px;
width: 50%;
position: relative;
transition: all 3s ease-in-out;
}
.popup h2 {
margin-top: 0;
color: #333;
font-family: Tahoma, Arial, sans-serif;
}
.popup .close {
position: absolute;
top: 20px;
right: 30px;
transition: all 200ms;
font-size: 30px;
font-weight: bold;
text-decoration: none;
color: #333;
}
.popup .close:hover {
color: #06d85f;
}
.popup .content {
max-height: 80%;
overflow: auto;
text-align: left;
}
.popup .separator {
color: royalblue
}
@media screen and (max-width: 700px) {
.box {
width: 70%;
}
.popup {
width: 70%;
}
}
</style>
</head>
<body>
<h1>API Test Report</h1>
<h2>Summary</h2>
<table id="summary">
<tr>
<th>START AT</th>
<td colspan="4">{{.Time.StartAt}}</td>
</tr>
<tr>
<th>DURATION</th>
<td colspan="4">{{ .Time.Duration }} seconds</td>
</tr>
<tr>
<th>PLATFORM</th>
<td>HttpRunnerPlus {{ .Platform.HttprunnerVersion }}</td>
<td>{{ .Platform.GoVersion }}</td>
<td colspan="2">{{ .Platform.Platform }}</td>
</tr>
<tr>
<th>STAT</th>
<th colspan="2">TESTCASES (success/fail)</th>
<th colspan="2">TESTSTEPS (success/fail/error/skip)</th>
</tr>
<tr>
<td>total (details) =></td>
<td colspan="2">{{.Stat.TestCases.Total}} ({{.Stat.TestCases.Success}}/{{.Stat.TestCases.Fail}})</td>
<td colspan="2">{{.Stat.TestSteps.Total}} ({{.Stat.TestSteps.Successes}}/0/{{.Stat.TestSteps.Failures}}/0)</td>
</tr>
</table>
<h2>Details</h2>
{{ range $suite_index, $detail := .Details }}
<h3>{{.Name}}</h3>
<table id="suite_{{$suite_index}}" class="details">
<tr>
<td>TOTAL: {{.Stat.Total}}</td>
<td>SUCCESS: {{.Stat.Successes}}</td>
<td>FAILED: 0</td>
<td>ERROR: {{.Stat.Failures}}</td>
<td>SKIPPED: 0</td>
</tr>
<tr>
<th>Status</th>
<th colspan="2">Name</th>
<th>Response Time</th>
<th>Detail</th>
</tr>
{{- range $loop_index, $record := .Records }}
{{- with $record}}
{{- $status := "error"}}
{{- if .Success }} {{ $status = "success" }} {{ end }}
<tr id="record_{{$suite_index}}_{{$loop_index}}">
<th class={{$status}} style="width:5em;">{{$status}}</th>
<td colspan="2">{{.Name}}</td>
<td style="text-align:center;width:6em;">{{ .Elapsed }} ms</td>
<td class="detail">
<a class="button" href="#popup_log_{{$suite_index}}_{{$loop_index}}">log</a>
<div id="popup_log_{{$suite_index}}_{{$loop_index}}" class="overlay">
<div class="popup">
<h2>Request and Response data</h2>
<a class="close" href="#record_{{$suite_index}}_{{$loop_index}}">&times;</a>
<div class="content">
<h3>Name: {{ .Name }}</h3>
{{- if .Data}}
<h3>Request:</h3>
<div style="overflow: auto">
<table>
{{- range $key, $value := .Data.ReqResps.Request}}
<tr>
<th>{{$key}}</th>
<td align="left">
{{- if eq $key "headers" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "params" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "cookies" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else }}
<pre>{{$value}}</pre>
{{- end }}
</td>
</tr>
{{- end }}
</table>
</div>
<h3>Response:</h3>
<div style="overflow: auto">
<table>
{{- range $key, $value := .Data.ReqResps.Response}}
<tr>
<th>{{$key}}</th>
<td align="left">
{{- if eq $key "headers" }}
{{- range $k, $v := $value}}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "cookies" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else }}
<pre>{{ $value }}</pre>
{{- end }}
</td>
</tr>
{{- end }}
</table>
</div>
<h3>Validators:</h3>
<div style="overflow: auto">
{{- if .Data.Validators }}
<table>
<tr>
<th>check</th>
<th>comparator</th>
<th>expect value</th>
<th>actual value</th>
</tr>
{{- range $validator := .Data.Validators }}
<tr>
{{- if eq $validator.CheckResult "pass" }}
<td class="passed">
{{- else if eq $validator.CheckResult "fail" }}
<td class="failed">
{{- else if eq $validator.CheckResult "unchecked" }}
<td class="unchecked">
{{- end }}
{{$validator.Check}}
</td>
<td>{{$validator.Assert}}</td>
<td>{{$validator.Expect}}</td>
<td>{{$validator.CheckValue}}</td>
</tr>
{{- end }}
</table>
{{- end }}
<h3>Statistics:</h3>
<div style="overflow: auto">
<table>
<tr>
<th>content_size(bytes)</th>
<td>{{ .ContentSize }}</td>
</tr>
<tr>
<th>response_time(ms)</th>
<td>{{ .Elapsed }}</td>
</tr>
<tr>
<th>elapsed(ms)</th>
<td>{{ .Elapsed }}</td>
</tr>
</table>
</div>
</div>
{{- end }}
</div>
</div>
</div>
{{ if .Attachment }}
<a class="button" href="#popup_attachment_{{$suite_index}}_{{$loop_index}}">traceback</a>
<div id="popup_attachment_{{$suite_index}}_{{$loop_index}}" class="overlay">
<div class="popup">
<h2>Traceback Message</h2>
<a class="close" href="#record_{{$suite_index}}_{{$loop_index}}">&times;</a>
<div class="content">
<pre>{{ .Attachment }}</pre>
</div>
</div>
</div>
{{- end }}
</td>
</tr>
{{- end }}
{{- end }}
</table>
{{- end }}
</body>
+260
View File
@@ -0,0 +1,260 @@
package scaffold
import "github.com/httprunner/hrp"
var demoTestCase = &hrp.TestCase{
Config: hrp.NewConfig("demo with complex mechanisms").
SetBaseURL("https://postman-echo.com").
WithVariables(map[string]interface{}{ // global level variables
"n": "${sum_ints(1, 2, 2)}",
"a": "${sum(10, 2.3)}",
"b": 3.45,
"varFoo1": "${gen_random_string($n)}",
"varFoo2": "${max($a, $b)}", // 12.3; eval with built-in function
}),
TestSteps: []hrp.IStep{
hrp.NewStep("transaction 1 start").StartTransaction("tran1"), // start transaction
hrp.NewStep("get with params").
WithVariables(map[string]interface{}{ // step level variables
"n": 3, // inherit config level variables if not set in step level, a/varFoo1
"b": 34.5, // override config level variable if existed, n/b/varFoo2
"varFoo2": "${max($a, $b)}", // 34.5; override variable b and eval again
"name": "get with params",
}).
SetupHook("${setup_hook_example($name)}").
GET("/get").
TeardownHook("${teardown_hook_example($name)}").
WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params
WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers
Extract().
WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath
Validate().
AssertEqual("status_code", 200, "check response status code"). // validate response status code
AssertStartsWith("headers.\"Content-Type\"", "application/json", ""). // validate response header
AssertLengthEqual("body.args.foo1", 5, "check args foo1"). // validate response body with jmespath
AssertLengthEqual("$varFoo1", 5, "check args foo1"). // assert with extracted variable from current step
AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string
hrp.NewStep("transaction 1 end").EndTransaction("tran1"), // end transaction
hrp.NewStep("post json data").
POST("/post").
WithBody(map[string]interface{}{
"foo1": "$varFoo1", // reference former extracted variable
"foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here
}).
Validate().
AssertEqual("status_code", 200, "check status code").
AssertLengthEqual("body.json.foo1", 5, "check args foo1").
AssertEqual("body.json.foo2", 12.3, "check args foo2"),
hrp.NewStep("post form data").
POST("/post").
WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}).
WithBody(map[string]interface{}{
"foo1": "$varFoo1", // reference former extracted variable
"foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here
"time": "${get_timestamp()}",
}).
Extract().
WithJmesPath("body.form.time", "varTime").
Validate().
AssertEqual("status_code", 200, "check status code").
AssertLengthEqual("body.form.foo1", 5, "check args foo1").
AssertEqual("body.form.foo2", "12.3", "check args foo2"), // form data will be converted to string
hrp.NewStep("get with timestamp").
GET("/get").WithParams(map[string]interface{}{"time": "$varTime"}).
Validate().
AssertLengthEqual("body.args.time", 13, "check extracted var timestamp"),
},
}
var demoTestCaseWithoutPlugin = &hrp.TestCase{
Config: hrp.NewConfig("demo without custom function plugin").
SetBaseURL("https://postman-echo.com").
WithVariables(map[string]interface{}{ // global level variables
"n": 5,
"a": 12.3,
"b": 3.45,
"varFoo1": "${gen_random_string($n)}",
"varFoo2": "${max($a, $b)}", // 12.3; eval with built-in function
}),
TestSteps: []hrp.IStep{
hrp.NewStep("transaction 1 start").StartTransaction("tran1"), // start transaction
hrp.NewStep("get with params").
WithVariables(map[string]interface{}{ // step level variables
"n": 3, // inherit config level variables if not set in step level, a/varFoo1
"b": 34.5, // override config level variable if existed, n/b/varFoo2
"varFoo2": "${max($a, $b)}", // 34.5; override variable b and eval again
"name": "get with params",
}).
GET("/get").
WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params
WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers
Extract().
WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath
Validate().
AssertEqual("status_code", 200, "check response status code"). // validate response status code
AssertStartsWith("headers.\"Content-Type\"", "application/json", ""). // validate response header
AssertLengthEqual("body.args.foo1", 5, "check args foo1"). // validate response body with jmespath
AssertLengthEqual("$varFoo1", 5, "check args foo1"). // assert with extracted variable from current step
AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string
hrp.NewStep("transaction 1 end").EndTransaction("tran1"), // end transaction
hrp.NewStep("post json data").
POST("/post").
WithBody(map[string]interface{}{
"foo1": "$varFoo1", // reference former extracted variable
"foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here
}).
Validate().
AssertEqual("status_code", 200, "check status code").
AssertLengthEqual("body.json.foo1", 5, "check args foo1").
AssertEqual("body.json.foo2", 12.3, "check args foo2"),
hrp.NewStep("post form data").
POST("/post").
WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}).
WithBody(map[string]interface{}{
"foo1": "$varFoo1", // reference former extracted variable
"foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here
"time": "${get_timestamp()}",
}).
Extract().
WithJmesPath("body.form.time", "varTime").
Validate().
AssertEqual("status_code", 200, "check status code").
AssertLengthEqual("body.form.foo1", 5, "check args foo1").
AssertEqual("body.form.foo2", "12.3", "check args foo2"), // form data will be converted to string
hrp.NewStep("get with timestamp").
GET("/get").WithParams(map[string]interface{}{"time": "$varTime"}).
Validate().
AssertLengthEqual("body.args.time", 13, "check extracted var timestamp"),
},
}
// debugtalk.go
var demoGoPlugin = `package main
import (
"fmt"
"github.com/httprunner/funplugin/fungo"
)
func SumTwoInt(a, b int) int {
return a + b
}
func SumInts(args ...int) int {
var sum int
for _, arg := range args {
sum += arg
}
return sum
}
func Sum(args ...interface{}) (interface{}, error) {
var sum float64
for _, arg := range args {
switch v := arg.(type) {
case int:
sum += float64(v)
case float64:
sum += v
default:
return nil, fmt.Errorf("unexpected type: %T", arg)
}
}
return sum, nil
}
func SetupHookExample(args string) string {
return fmt.Sprintf("step name: %v, setup...", args)
}
func TeardownHookExample(args string) string {
return fmt.Sprintf("step name: %v, teardown...", args)
}
func main() {
fungo.Register("sum_ints", SumInts)
fungo.Register("sum_two_int", SumTwoInt)
fungo.Register("sum", Sum)
fungo.Register("setup_hook_example", SetupHookExample)
fungo.Register("teardown_hook_example", TeardownHookExample)
fungo.Serve()
}
`
// debugtalk.py
var demoPyPlugin = `import logging
from typing import List
import funppy
def sum(*args):
result = 0
for arg in args:
result += arg
return result
def sum_ints(*args: List[int]) -> int:
result = 0
for arg in args:
result += arg
return result
def sum_two_int(a: int, b: int) -> int:
return a + b
def sum_two_string(a: str, b: str) -> str:
return a + b
def sum_strings(*args: List[str]) -> str:
result = ""
for arg in args:
result += arg
return result
def concatenate(*args: List[str]) -> str:
result = ""
for arg in args:
result += str(arg)
return result
def setup_hook_example(name):
logging.warning("setup_hook_example")
return f"setup_hook_example: {name}"
def teardown_hook_example(name):
logging.warning("teardown_hook_example")
return f"teardown_hook_example: {name}"
if __name__ == '__main__':
funppy.register("sum", sum)
funppy.register("sum_ints", sum_ints)
funppy.register("concatenate", concatenate)
funppy.register("sum_two_int", sum_two_int)
funppy.register("sum_two_string", sum_two_string)
funppy.register("sum_strings", sum_strings)
funppy.register("setup_hook_example", setup_hook_example)
funppy.register("teardown_hook_example", teardown_hook_example)
funppy.serve()
`
// .gitignore
var demoIgnoreContent = `.env
reports/
*.so
.vscode/
.idea/
.DS_Store
output/
# plugin
debugtalk.bin
debugtalk.so
`
// .env
var demoEnvContent = `USERNAME=debugtalk
PASSWORD=123456
`
+75
View File
@@ -0,0 +1,75 @@
package scaffold
import (
"os"
"os/exec"
"testing"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp"
"github.com/httprunner/hrp/internal/builtin"
)
var (
demoTestCaseJSONPath hrp.TestCasePath = "../../examples/demo.json"
demoTestCaseYAMLPath hrp.TestCasePath = "../../examples/demo.yaml"
)
func buildHashicorpPlugin() {
log.Info().Msg("[init] build hashicorp go plugin")
cmd := exec.Command("go", "build",
"-o", "../../examples/debugtalk.bin",
"../../examples/plugin/hashicorp.go", "../../examples/plugin/debugtalk.go")
if err := cmd.Run(); err != nil {
panic(err)
}
}
func removeHashicorpPlugin() {
log.Info().Msg("[teardown] remove hashicorp plugin")
os.Remove("../../examples/debugtalk.bin")
}
func TestGenDemoTestCase(t *testing.T) {
tCase, _ := demoTestCase.ToTCase()
err := builtin.Dump2JSON(tCase, demoTestCaseJSONPath.ToString())
if err != nil {
t.Fail()
}
err = builtin.Dump2YAML(tCase, demoTestCaseYAMLPath.ToString())
if err != nil {
t.Fail()
}
}
func TestExampleDemo(t *testing.T) {
buildHashicorpPlugin()
defer removeHashicorpPlugin()
demoTestCase.Config.Path = "../../examples/debugtalk.bin"
err := hrp.NewRunner(nil).Run(demoTestCase) // hrp.Run(demoTestCase)
if err != nil {
t.Fail()
}
}
func TestJsonDemo(t *testing.T) {
buildHashicorpPlugin()
defer removeHashicorpPlugin()
err := hrp.NewRunner(nil).Run(&demoTestCaseJSONPath) // hrp.Run(testCase)
if err != nil {
t.Fail()
}
}
func TestYamlDemo(t *testing.T) {
buildHashicorpPlugin()
defer removeHashicorpPlugin()
err := hrp.NewRunner(nil).Run(&demoTestCaseYAMLPath) // hrp.Run(testCase)
if err != nil {
t.Fail()
}
}
+150
View File
@@ -0,0 +1,150 @@
package scaffold
import (
"fmt"
"os"
"os/exec"
"path"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/funplugin/shared"
"github.com/httprunner/hrp"
"github.com/httprunner/hrp/internal/builtin"
"github.com/httprunner/hrp/internal/ga"
)
type PluginType string
const (
Ignore PluginType = "ignore"
Py PluginType = "py"
Go PluginType = "go"
)
func CreateScaffold(projectName string, pluginType PluginType) error {
// report event
ga.SendEvent(ga.EventTracking{
Category: "Scaffold",
Action: "hrp startproject",
})
// check if projectName exists
if _, err := os.Stat(projectName); err == nil {
log.Warn().Str("projectName", projectName).
Msg("project name already exists, please specify a new one.")
return fmt.Errorf("project name already exists")
}
log.Info().
Str("projectName", projectName).
Str("pluginType", string(pluginType)).
Msg("create new scaffold project")
// create project folders
if err := builtin.CreateFolder(projectName); err != nil {
return err
}
if err := builtin.CreateFolder(path.Join(projectName, "har")); err != nil {
return err
}
if err := builtin.CreateFolder(path.Join(projectName, "testcases")); err != nil {
return err
}
if err := builtin.CreateFolder(path.Join(projectName, "reports")); err != nil {
return err
}
// create demo testcases
var tCase *hrp.TCase
if pluginType == Ignore {
tCase, _ = demoTestCaseWithoutPlugin.ToTCase()
} else {
tCase, _ = demoTestCase.ToTCase()
}
err := builtin.Dump2JSON(tCase, path.Join(projectName, "testcases", "demo.json"))
if err != nil {
log.Error().Err(err).Msg("create demo.json testcase failed")
return err
}
err = builtin.Dump2YAML(tCase, path.Join(projectName, "testcases", "demo.yaml"))
if err != nil {
log.Error().Err(err).Msg("create demo.yml testcase failed")
return err
}
// create .gitignore
if err := builtin.CreateFile(path.Join(projectName, ".gitignore"), demoIgnoreContent); err != nil {
return err
}
// create .env
if err := builtin.CreateFile(path.Join(projectName, ".env"), demoEnvContent); err != nil {
return err
}
// create debugtalk function plugin
switch pluginType {
case Ignore:
log.Info().Msg("skip creating function plugin")
return nil
case Py:
return createPythonPlugin(projectName)
case Go:
return createGoPlugin(projectName)
}
return nil
}
func createGoPlugin(projectName string) error {
log.Info().Msg("start to create hashicorp go plugin")
// check go sdk
if err := builtin.ExecCommand(exec.Command("go", "version"), projectName); err != nil {
return errors.Wrap(err, "go sdk not installed")
}
// create debugtalk.go
pluginDir := path.Join(projectName, "plugin")
if err := builtin.CreateFolder(pluginDir); err != nil {
return err
}
pluginFile := path.Join(pluginDir, "debugtalk.go")
if err := builtin.CreateFile(pluginFile, demoGoPlugin); err != nil {
return err
}
// create go mod
if err := builtin.ExecCommand(exec.Command("go", "mod", "init", "plugin"), pluginDir); err != nil {
return err
}
// download plugin dependency
if err := builtin.ExecCommand(exec.Command("go", "get", "github.com/httprunner/funplugin"), pluginDir); err != nil {
return err
}
// build plugin debugtalk.bin
if err := builtin.ExecCommand(exec.Command("go", "build", "-o", path.Join("..", "debugtalk.bin"), "debugtalk.go"), pluginDir); err != nil {
return err
}
return nil
}
func createPythonPlugin(projectName string) error {
log.Info().Msg("start to create hashicorp python plugin")
// create debugtalk.py
pluginFile := path.Join(projectName, "debugtalk.py")
if err := builtin.CreateFile(pluginFile, demoPyPlugin); err != nil {
return err
}
// create python venv
if _, err := shared.PreparePython3Venv(pluginFile); err != nil {
return err
}
return nil
}
+3
View File
@@ -0,0 +1,3 @@
package version
const VERSION = "v0.8.0"