diff --git a/.github/workflows/hrp-scaffold.yml b/.github/workflows/hrp-scaffold.yml index 07ec0241..06b8736a 100644 --- a/.github/workflows/hrp-scaffold.yml +++ b/.github/workflows/hrp-scaffold.yml @@ -5,7 +5,6 @@ on: branches: - master pull_request: - types: [synchronize] jobs: scaffold-with-python-plugin: diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml index cf551062..5dcb41dd 100644 --- a/.github/workflows/smoketest.yml +++ b/.github/workflows/smoketest.yml @@ -5,7 +5,6 @@ on: branches: - master pull_request: - types: [synchronize] jobs: smoke-test: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 879c63e9..a02075bf 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -5,7 +5,6 @@ on: branches: - master pull_request: - types: [synchronize] jobs: py-httprunner: diff --git a/hrp/boomer.go b/hrp/boomer.go index 8d49296f..b710a640 100644 --- a/hrp/boomer.go +++ b/hrp/boomer.go @@ -95,8 +95,9 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend Name: config.Name, Weight: config.Weight, Fn: func() { - runner := hrpRunner.newCaseRunner(testcase) - runner.parser.plugin = plugin + sessionRunner := hrpRunner.NewSessionRunner(testcase) + sessionRunner.init() + sessionRunner.parser.plugin = plugin testcaseSuccess := true // flag whole testcase result var transactionSuccess = true // flag current transaction result @@ -115,27 +116,27 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend } } - if err := runner.parseConfig(caseConfig); err != nil { + if err := sessionRunner.parseConfig(caseConfig); err != nil { log.Error().Err(err).Msg("parse config failed") return } startTime := time.Now() - for index, step := range testcase.TestSteps { - stepData, err := runner.runStep(index, caseConfig) + for _, step := range testcase.TestSteps { + stepResult, err := step.Run(sessionRunner) if err != nil { // step failed var elapsed int64 - if stepData != nil { - elapsed = stepData.Elapsed + if stepResult != nil { + elapsed = stepResult.Elapsed } - b.RecordFailure(step.Type(), step.Name(), elapsed, err.Error()) + b.RecordFailure(string(step.Type()), step.Name(), elapsed, err.Error()) // update flag testcaseSuccess = false transactionSuccess = false - if runner.hrpRunner.failfast { + if sessionRunner.hrpRunner.failfast { log.Error().Msg("abort running due to failfast setting") break } @@ -144,28 +145,27 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend } // step success - if stepData.StepType == stepTypeTransaction { + if stepResult.StepType == stepTypeTransaction { // transaction // FIXME: support nested transactions if step.ToStruct().Transaction.Type == transactionEnd { // only record when transaction ends - b.RecordTransaction(stepData.Name, transactionSuccess, stepData.Elapsed, 0) + b.RecordTransaction(stepResult.Name, transactionSuccess, stepResult.Elapsed, 0) transactionSuccess = true // reset flag for next transaction } - } else if stepData.StepType == stepTypeRendezvous { + } else if stepResult.StepType == stepTypeRendezvous { // rendezvous - // TODO: implement rendezvous in boomer - } else if stepData.StepType == stepTypeThinkTime { + } else if stepResult.StepType == stepTypeThinkTime { // think time // no record required } else { // request or testcase step - b.RecordSuccess(step.Type(), step.Name(), stepData.Elapsed, stepData.ContentSize) + b.RecordSuccess(string(step.Type()), step.Name(), stepResult.Elapsed, stepResult.ContentSize) } } endTime := time.Now() // report duration for transaction without end - for name, transaction := range runner.transactions { + for name, transaction := range sessionRunner.transactions { if len(transaction) == 1 { // if transaction end time not exists, use testcase end time instead duration := endTime.Sub(transaction[transactionStart]) diff --git a/hrp/config.go b/hrp/config.go new file mode 100644 index 00000000..5af9ae87 --- /dev/null +++ b/hrp/config.go @@ -0,0 +1,216 @@ +package hrp + +import ( + "math/rand" + "reflect" + "sync" + "time" + + "github.com/httprunner/httprunner/hrp/internal/builtin" +) + +// NewConfig returns a new constructed testcase config with specified testcase name. +func NewConfig(name string) *TConfig { + return &TConfig{ + Name: name, + Variables: make(map[string]interface{}), + } +} + +// TConfig represents config data structure for testcase. +// Each testcase should contain one config part. +type TConfig struct { + Name string `json:"name" yaml:"name"` // required + Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` + BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` + ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` + ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` + Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path +} + +// WithVariables sets variables for current testcase. +func (c *TConfig) WithVariables(variables map[string]interface{}) *TConfig { + c.Variables = variables + return c +} + +// SetBaseURL sets base URL for current testcase. +func (c *TConfig) SetBaseURL(baseURL string) *TConfig { + c.BaseURL = baseURL + return c +} + +// SetHeaders sets global headers for current testcase. +func (c *TConfig) SetHeaders(headers map[string]string) *TConfig { + c.Headers = headers + return c +} + +// SetVerifySSL sets whether to verify SSL for current testcase. +func (c *TConfig) SetVerifySSL(verify bool) *TConfig { + c.Verify = verify + return c +} + +// WithParameters sets parameters for current testcase. +func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig { + c.Parameters = parameters + return c +} + +// SetThinkTime sets think time config for current testcase. +func (c *TConfig) SetThinkTime(strategy thinkTimeStrategy, cfg interface{}, limit float64) *TConfig { + c.ThinkTimeSetting = &ThinkTimeConfig{strategy, cfg, limit} + return c +} + +// ExportVars specifies variable names to export for current testcase. +func (c *TConfig) ExportVars(vars ...string) *TConfig { + c.Export = vars + return c +} + +// SetWeight sets weight for current testcase, which is used in load testing. +func (c *TConfig) SetWeight(weight int) *TConfig { + c.Weight = weight + return c +} + +type ThinkTimeConfig struct { + Strategy thinkTimeStrategy `json:"strategy,omitempty" yaml:"strategy,omitempty"` // default、random、limit、multiply、ignore + Setting interface{} `json:"setting,omitempty" yaml:"setting,omitempty"` // random(map): {"min_percentage": 0.5, "max_percentage": 1.5}; 10、multiply(float64): 1.5 + Limit float64 `json:"limit,omitempty" yaml:"limit,omitempty"` // limit think time no more than specific time, ignore if value <= 0 +} + +func (ttc *ThinkTimeConfig) checkThinkTime() { + if ttc == nil { + return + } + // unset strategy, set default strategy + if ttc.Strategy == "" { + ttc.Strategy = thinkTimeDefault + } + // check think time + if ttc.Strategy == thinkTimeRandomPercentage { + if ttc.Setting == nil || reflect.TypeOf(ttc.Setting).Kind() != reflect.Map { + ttc.Setting = thinkTimeDefaultRandom + return + } + value, ok := ttc.Setting.(map[string]interface{}) + if !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["min_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["max_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + left, err := builtin.Interface2Float64(value["min_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + right, err := builtin.Interface2Float64(value["max_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + ttc.Setting = map[string]float64{"min_percentage": left, "max_percentage": right} + } else if ttc.Strategy == thinkTimeMultiply { + if ttc.Setting == nil { + ttc.Setting = float64(0) // default + return + } + value, err := builtin.Interface2Float64(ttc.Setting) + if err != nil { + ttc.Setting = float64(0) // default + return + } + ttc.Setting = value + } else if ttc.Strategy != thinkTimeIgnore { + // unrecognized strategy, set default strategy + ttc.Strategy = thinkTimeDefault + } +} + +type thinkTimeStrategy string + +const ( + thinkTimeDefault thinkTimeStrategy = "default" // as recorded + thinkTimeRandomPercentage thinkTimeStrategy = "random_percentage" // use random percentage of recorded think time + thinkTimeMultiply thinkTimeStrategy = "multiply" // multiply recorded think time + thinkTimeIgnore thinkTimeStrategy = "ignore" // ignore recorded think time +) + +const ( + thinkTimeDefaultMultiply = 1 +) + +var ( + thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} +) + +type TParamsConfig struct { + Strategy interface{} `json:"strategy,omitempty" yaml:"strategy,omitempty"` // map[string]string、string + Iteration int `json:"iteration,omitempty" yaml:"iteration,omitempty"` + Iterators []*Iterator `json:"parameterIterator,omitempty" yaml:"parameterIterator,omitempty"` // 保存参数的迭代器 +} + +type Iterator struct { + sync.Mutex + data iteratorParamsType + strategy iteratorStrategyType // random, sequential + iteration int + index int +} + +type iteratorStrategyType string + +const ( + strategyRandom iteratorStrategyType = "random" + strategySequential iteratorStrategyType = "Sequential" +) + +type iteratorParamsType []map[string]interface{} + +func (params iteratorParamsType) Iterator() *Iterator { + return &Iterator{ + data: params, + iteration: len(params), + index: 0, + } +} + +func (iter *Iterator) HasNext() bool { + if iter.iteration == -1 { + return true + } + return iter.index < iter.iteration +} + +func (iter *Iterator) Next() (value map[string]interface{}) { + iter.Lock() + defer iter.Unlock() + if len(iter.data) == 0 { + iter.index++ + return map[string]interface{}{} + } + if iter.strategy == strategyRandom { + randSource := rand.New(rand.NewSource(time.Now().Unix())) + randIndex := randSource.Intn(len(iter.data)) + value = iter.data[randIndex] + } else { + value = iter.data[iter.index%len(iter.data)] + } + iter.index++ + return value +} diff --git a/hrp/extract.go b/hrp/extract.go deleted file mode 100644 index b4269939..00000000 --- a/hrp/extract.go +++ /dev/null @@ -1,33 +0,0 @@ -package hrp - -import "fmt" - -// StepRequestExtraction implements IStep interface. -type StepRequestExtraction struct { - step *TStep -} - -// WithJmesPath sets the JMESPath expression to extract from the response. -func (s *StepRequestExtraction) WithJmesPath(jmesPath string, varName string) *StepRequestExtraction { - s.step.Extract[varName] = jmesPath - return s -} - -// Validate switches to step validation. -func (s *StepRequestExtraction) Validate() *StepRequestValidation { - return &StepRequestValidation{ - step: s.step, - } -} - -func (s *StepRequestExtraction) Name() string { - return s.step.Name -} - -func (s *StepRequestExtraction) Type() string { - return fmt.Sprintf("request-%v", s.step.Request.Method) -} - -func (s *StepRequestExtraction) ToStruct() *TStep { - return s.step -} diff --git a/hrp/internal/har2case/core.go b/hrp/internal/har2case/core.go index 10d75f0a..5852ee4c 100644 --- a/hrp/internal/har2case/core.go +++ b/hrp/internal/har2case/core.go @@ -177,7 +177,7 @@ type tStep struct { } func (s *tStep) makeRequestMethod(entry *Entry) error { - s.Request.Method = entry.Request.Method + s.Request.Method = hrp.HTTPMethod(entry.Request.Method) return nil } diff --git a/hrp/models.go b/hrp/models.go deleted file mode 100644 index 4247cf4a..00000000 --- a/hrp/models.go +++ /dev/null @@ -1,462 +0,0 @@ -package hrp - -import ( - "fmt" - "math/rand" - "reflect" - "runtime" - "sync" - "time" - - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/version" -) - -const ( - httpGET string = "GET" - httpHEAD string = "HEAD" - httpPOST string = "POST" - httpPUT string = "PUT" - httpDELETE string = "DELETE" - httpOPTIONS string = "OPTIONS" - httpPATCH string = "PATCH" -) - -// TConfig represents config data structure for testcase. -// Each testcase should contain one config part. -type TConfig struct { - Name string `json:"name" yaml:"name"` // required - Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` - BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` - Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` - Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` - ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` - ThinkTime *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` - Export []string `json:"export,omitempty" yaml:"export,omitempty"` - Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` - Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path -} - -type TParamsConfig struct { - Strategy interface{} `json:"strategy,omitempty" yaml:"strategy,omitempty"` // map[string]string、string - Iteration int `json:"iteration,omitempty" yaml:"iteration,omitempty"` - Iterators []*Iterator `json:"parameterIterator,omitempty" yaml:"parameterIterator,omitempty"` // 保存参数的迭代器 -} - -const ( - strategyRandom string = "random" - strategySequential string = "Sequential" -) - -type ThinkTimeConfig struct { - Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` // default、random、limit、multiply、ignore - Setting interface{} `json:"setting,omitempty" yaml:"setting,omitempty"` // random(map): {"min_percentage": 0.5, "max_percentage": 1.5}; 10、multiply(float64): 1.5 - Limit float64 `json:"limit,omitempty" yaml:"limit,omitempty"` // limit think time no more than specific time, ignore if value <= 0 -} - -const ( - thinkTimeDefault string = "default" // as recorded - thinkTimeRandomPercentage string = "random_percentage" // use random percentage of recorded think time - thinkTimeMultiply string = "multiply" // multiply recorded think time - thinkTimeIgnore string = "ignore" // ignore recorded think time -) - -const ( - thinkTimeDefaultMultiply = 1 -) - -var ( - thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} -) - -func (ttc *ThinkTimeConfig) checkThinkTime() { - if ttc == nil { - return - } - // unset strategy, set default strategy - if ttc.Strategy == "" { - ttc.Strategy = thinkTimeDefault - } - // check think time - if ttc.Strategy == thinkTimeRandomPercentage { - if ttc.Setting == nil || reflect.TypeOf(ttc.Setting).Kind() != reflect.Map { - ttc.Setting = thinkTimeDefaultRandom - return - } - value, ok := ttc.Setting.(map[string]interface{}) - if !ok { - ttc.Setting = thinkTimeDefaultRandom - return - } - if _, ok := value["min_percentage"]; !ok { - ttc.Setting = thinkTimeDefaultRandom - return - } - if _, ok := value["max_percentage"]; !ok { - ttc.Setting = thinkTimeDefaultRandom - return - } - left, err := builtin.Interface2Float64(value["min_percentage"]) - if err != nil { - ttc.Setting = thinkTimeDefaultRandom - return - } - right, err := builtin.Interface2Float64(value["max_percentage"]) - if err != nil { - ttc.Setting = thinkTimeDefaultRandom - return - } - ttc.Setting = map[string]float64{"min_percentage": left, "max_percentage": right} - } else if ttc.Strategy == thinkTimeMultiply { - if ttc.Setting == nil { - ttc.Setting = float64(0) // default - return - } - value, err := builtin.Interface2Float64(ttc.Setting) - if err != nil { - ttc.Setting = float64(0) // default - return - } - ttc.Setting = value - } else if ttc.Strategy != thinkTimeIgnore { - // unrecognized strategy, set default strategy - ttc.Strategy = thinkTimeDefault - } -} - -type paramsType []map[string]interface{} - -type Iterator struct { - sync.Mutex - data paramsType - strategy string // random, sequential - iteration int - index int -} - -func (params paramsType) Iterator() *Iterator { - return &Iterator{ - data: params, - iteration: len(params), - index: 0, - } -} - -func (iter *Iterator) HasNext() bool { - if iter.iteration == -1 { - return true - } - return iter.index < iter.iteration -} - -func (iter *Iterator) Next() (value map[string]interface{}) { - iter.Lock() - defer iter.Unlock() - if len(iter.data) == 0 { - iter.index++ - return map[string]interface{}{} - } - if iter.strategy == strategyRandom { - randSource := rand.New(rand.NewSource(time.Now().Unix())) - randIndex := randSource.Intn(len(iter.data)) - value = iter.data[randIndex] - } else { - value = iter.data[iter.index%len(iter.data)] - } - iter.index++ - return value -} - -// Request represents HTTP request data structure. -// This is used for teststep. -type Request struct { - Method string `json:"method" yaml:"method"` // required - URL string `json:"url" yaml:"url"` // required - Params map[string]interface{} `json:"params,omitempty" yaml:"params,omitempty"` - Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` - Cookies map[string]string `json:"cookies,omitempty" yaml:"cookies,omitempty"` - Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` - Json interface{} `json:"json,omitempty" yaml:"json,omitempty"` - Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` - Timeout float32 `json:"timeout,omitempty" yaml:"timeout,omitempty"` - AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"` - Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` -} - -type API struct { - Name string `json:"name" yaml:"name"` // required - Request *Request `json:"request,omitempty" yaml:"request,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` - SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` - TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` - Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` - Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` - Export []string `json:"export,omitempty" yaml:"export,omitempty"` - Path string -} - -func (api *API) GetPath() string { - return api.Path -} - -func (api *API) ToAPI() (*API, error) { - return api, nil -} - -// Validator represents validator for one HTTP response. -type Validator struct { - Check string `json:"check" yaml:"check"` // get value with jmespath - Assert string `json:"assert" yaml:"assert"` - Expect interface{} `json:"expect" yaml:"expect"` - Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional -} - -// IAPI represents interface for api, -// includes API and APIPath. -type IAPI interface { - GetPath() string - ToAPI() (*API, error) -} - -// TStep represents teststep data structure. -// Each step maybe two different type: make one HTTP request or reference another testcase. -type TStep struct { - Name string `json:"name" yaml:"name"` // required - Request *Request `json:"request,omitempty" yaml:"request,omitempty"` - API interface{} `json:"api,omitempty" yaml:"api,omitempty"` // *APIPath or *API - TestCase interface{} `json:"testcase,omitempty" yaml:"testcase,omitempty"` // *TestCasePath or *TestCase - Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"` - Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,omitempty"` - ThinkTime *ThinkTime `json:"think_time,omitempty" yaml:"think_time,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` - SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` - TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` - Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` - Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` - Export []string `json:"export,omitempty" yaml:"export,omitempty"` -} - -type stepType string - -const ( - stepTypeRequest stepType = "request" - stepTypeTestCase stepType = "testcase" - stepTypeTransaction stepType = "transaction" - stepTypeRendezvous stepType = "rendezvous" - stepTypeThinkTime stepType = "thinktime" -) - -type ThinkTime struct { - Time float64 `json:"time" yaml:"time"` -} - -type transactionType string - -const ( - transactionStart transactionType = "start" - transactionEnd transactionType = "end" -) - -type Transaction struct { - Name string `json:"name" yaml:"name"` - Type transactionType `json:"type" yaml:"type"` -} - -const ( - defaultRendezvousTimeout int64 = 5000 - defaultRendezvousPercent float32 = 1.0 -) - -type Rendezvous struct { - Name string `json:"name" yaml:"name"` // required - Percent float32 `json:"percent,omitempty" yaml:"percent,omitempty"` // default to 1(100%) - Number int64 `json:"number,omitempty" yaml:"number,omitempty"` - Timeout int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // milliseconds - cnt int64 - releasedFlag uint32 - spawnDoneFlag uint32 - wg sync.WaitGroup - timerResetChan chan struct{} - activateChan chan struct{} - releaseChan chan struct{} - once *sync.Once - lock sync.Mutex -} - -// TCase represents testcase data structure. -// Each testcase includes one public config and several sequential teststeps. -type TCase struct { - Config *TConfig `json:"config" yaml:"config"` - TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` -} - -// IStep represents interface for all types for teststeps, includes: -// StepRequest, StepRequestWithOptionalArgs, StepRequestValidation, StepRequestExtraction, -// StepTestCaseWithOptionalArgs, -// StepTransaction, StepRendezvous. -type IStep interface { - Name() string - Type() string - ToStruct() *TStep -} - -// ITestCase represents interface for testcases, -// includes TestCase and TestCasePath. -type ITestCase interface { - GetPath() string - ToTestCase() (*TestCase, error) -} - -// TestCase is a container for one testcase, which is used for testcase runner. -// TestCase implements ITestCase interface. -type TestCase struct { - Config *TConfig - TestSteps []IStep -} - -func (tc *TestCase) GetPath() string { - return tc.Config.Path -} - -func (tc *TestCase) ToTestCase() (*TestCase, error) { - return tc, nil -} - -func (tc *TestCase) ToTCase() *TCase { - tCase := &TCase{ - Config: tc.Config, - } - for _, step := range tc.TestSteps { - tCase.TestSteps = append(tCase.TestSteps, step.ToStruct()) - } - return tCase -} - -type testCaseStat struct { - Total int `json:"total" yaml:"total"` - Success int `json:"success" yaml:"success"` - Fail int `json:"fail" yaml:"fail"` -} - -type testStepStat struct { - Total int `json:"total" yaml:"total"` - Successes int `json:"successes" yaml:"successes"` - Failures int `json:"failures" yaml:"failures"` -} - -type stat struct { - TestCases testCaseStat `json:"testcases" yaml:"test_cases"` - TestSteps testStepStat `json:"teststeps" yaml:"test_steps"` -} - -type testCaseTime struct { - StartAt time.Time `json:"start_at,omitempty" yaml:"start_at,omitempty"` - Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` -} - -type platform struct { - HttprunnerVersion string `json:"httprunner_version" yaml:"httprunner_version"` - GoVersion string `json:"go_version" yaml:"go_version"` - Platform string `json:"platform" yaml:"platform"` -} - -// Summary stores tests summary for current task execution, maybe include one or multiple testcases -type Summary struct { - Success bool `json:"success" yaml:"success"` - Stat *stat `json:"stat" yaml:"stat"` - Time *testCaseTime `json:"time" yaml:"time"` - Platform *platform `json:"platform" yaml:"platform"` - Details []*testCaseSummary `json:"details" yaml:"details"` -} - -func newOutSummary() *Summary { - platForm := &platform{ - HttprunnerVersion: version.VERSION, - GoVersion: runtime.Version(), - Platform: fmt.Sprintf("%v-%v", runtime.GOOS, runtime.GOARCH), - } - return &Summary{ - Success: true, - Stat: &stat{}, - Time: &testCaseTime{ - StartAt: time.Now(), - }, - Platform: platForm, - } -} - -func (s *Summary) appendCaseSummary(caseSummary *testCaseSummary) { - s.Success = s.Success && caseSummary.Success - s.Stat.TestCases.Total += 1 - s.Stat.TestSteps.Total += len(caseSummary.Records) - if caseSummary.Success { - s.Stat.TestCases.Success += 1 - } else { - s.Stat.TestCases.Fail += 1 - } - s.Stat.TestSteps.Successes += caseSummary.Stat.Successes - s.Stat.TestSteps.Failures += caseSummary.Stat.Failures - s.Details = append(s.Details, caseSummary) - s.Success = s.Success && caseSummary.Success -} - -type stepData struct { - Name string `json:"name" yaml:"name"` // step name - StepType stepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous - Success bool `json:"success" yaml:"success"` // step execution result - Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms) - Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` // session data or slice of step data - ContentSize int64 `json:"content_size" yaml:"content_size"` // response body length - ExportVars map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables - Attachment string `json:"attachment,omitempty" yaml:"attachment,omitempty"` // step error information -} - -type testCaseInOut struct { - ConfigVars map[string]interface{} `json:"config_vars" yaml:"config_vars"` - ExportVars map[string]interface{} `json:"export_vars" yaml:"export_vars"` -} - -// testCaseSummary stores tests summary for one testcase -type testCaseSummary struct { - Name string `json:"name" yaml:"name"` - Success bool `json:"success" yaml:"success"` - CaseId string `json:"case_id,omitempty" yaml:"case_id,omitempty"` // TODO - Stat *testStepStat `json:"stat" yaml:"stat"` - Time *testCaseTime `json:"time" yaml:"time"` - InOut *testCaseInOut `json:"in_out" yaml:"in_out"` - Log string `json:"log,omitempty" yaml:"log,omitempty"` // TODO - Records []*stepData `json:"records" yaml:"records"` -} - -type validationResult struct { - Validator - CheckValue interface{} `json:"check_value" yaml:"check_value"` - CheckResult string `json:"check_result" yaml:"check_result"` -} - -type reqResps struct { - Request interface{} `json:"request" yaml:"request"` - Response interface{} `json:"response" yaml:"response"` -} - -type address struct { - ClientIP string `json:"client_ip,omitempty" yaml:"client_ip,omitempty"` - ClientPort string `json:"client_port,omitempty" yaml:"client_port,omitempty"` - ServerIP string `json:"server_ip,omitempty" yaml:"server_ip,omitempty"` - ServerPort string `json:"server_port,omitempty" yaml:"server_port,omitempty"` -} - -type SessionData struct { - Success bool `json:"success" yaml:"success"` - ReqResps *reqResps `json:"req_resps" yaml:"req_resps"` - Address *address `json:"address,omitempty" yaml:"address,omitempty"` // TODO - Validators []*validationResult `json:"validators,omitempty" yaml:"validators,omitempty"` -} - -func newSessionData() *SessionData { - return &SessionData{ - Success: false, - ReqResps: &reqResps{}, - } -} diff --git a/hrp/parser.go b/hrp/parser.go index 10352938..cf81ed2c 100644 --- a/hrp/parser.go +++ b/hrp/parser.go @@ -553,18 +553,18 @@ func findallVariables(raw string) variableSet { return varSet } -func genCartesianProduct(paramsMap map[string]paramsType) paramsType { +func genCartesianProduct(paramsMap map[string]iteratorParamsType) iteratorParamsType { if len(paramsMap) == 0 { return nil } - var params []paramsType + var params []iteratorParamsType for _, v := range paramsMap { params = append(params, v) } - var cartesianProduct paramsType + var cartesianProduct iteratorParamsType cartesianProduct = params[0] for i := 0; i < len(params)-1; i++ { - var tempProduct paramsType + var tempProduct iteratorParamsType for _, param1 := range cartesianProduct { for _, param2 := range params[i+1] { tempProduct = append(tempProduct, mergeVariables(param1, param2)) @@ -575,14 +575,14 @@ func genCartesianProduct(paramsMap map[string]paramsType) paramsType { return cartesianProduct } -func parseParameters(parameters map[string]interface{}, variablesMapping map[string]interface{}) (map[string]paramsType, error) { +func parseParameters(parameters map[string]interface{}, variablesMapping map[string]interface{}) (map[string]iteratorParamsType, error) { if len(parameters) == 0 { return nil, nil } - parsedParametersSlice := make(map[string]paramsType) + parsedParametersSlice := make(map[string]iteratorParamsType) var err error for k, v := range parameters { - var parameterSlice paramsType + var parameterSlice iteratorParamsType rawValue := reflect.ValueOf(v) switch rawValue.Kind() { case reflect.String: @@ -662,7 +662,7 @@ func parseSlice(parameterName string, parameterContent interface{}) ([]map[strin } func initParameterIterator(cfg *TConfig, mode string) (err error) { - var parameters map[string]paramsType + var parameters map[string]iteratorParamsType parameters, err = parseParameters(cfg.Parameters, cfg.Variables) if err != nil { return err @@ -684,7 +684,7 @@ func initParameterIterator(cfg *TConfig, mode string) (err error) { // use strategy if configured cfg.ParametersSetting.Iterators = append( cfg.ParametersSetting.Iterators, - newIterator(v, rawValue.MapIndex(reflect.ValueOf(k)).Interface().(string), cfg.ParametersSetting.Iteration), + newIterator(v, rawValue.MapIndex(reflect.ValueOf(k)).Interface().(iteratorStrategyType), cfg.ParametersSetting.Iteration), ) } else { // use sequential strategy by default @@ -703,20 +703,20 @@ func initParameterIterator(cfg *TConfig, mode string) (err error) { } cfg.ParametersSetting.Iterators = append( cfg.ParametersSetting.Iterators, - newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration), + newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(iteratorStrategyType), cfg.ParametersSetting.Iteration), ) default: // default strategy: sequential, 仅生成一个的迭代器,该迭代器在参数笛卡尔积slice中顺序选取元素 cfg.ParametersSetting.Strategy = strategySequential cfg.ParametersSetting.Iterators = append( cfg.ParametersSetting.Iterators, - newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration), + newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(iteratorStrategyType), cfg.ParametersSetting.Iteration), ) } return nil } -func newIterator(parameters paramsType, strategy string, iteration int) *Iterator { +func newIterator(parameters iteratorParamsType, strategy iteratorStrategyType, iteration int) *Iterator { iter := parameters.Iterator() iter.strategy = strategy if iteration > 0 { diff --git a/hrp/plugin.go b/hrp/plugin.go index 12cc1156..a4beb671 100644 --- a/hrp/plugin.go +++ b/hrp/plugin.go @@ -115,3 +115,16 @@ func locateFile(startPath string, destFile string) (string, error) { return locateFile(parentDir, destFile) } + +func getProjectRootDirPath(path string) (rootDir string, err error) { + pluginPath, err := locatePlugin(path) + if err == nil { + rootDir = filepath.Dir(pluginPath) + return + } + + // failed to locate project root dir + // maybe project plugin debugtalk.xx is not exist + // use current dir instead + return os.Getwd() +} diff --git a/hrp/response.go b/hrp/response.go index fb6d49c3..e6328806 100644 --- a/hrp/response.go +++ b/hrp/response.go @@ -84,7 +84,7 @@ type responseObject struct { t *testing.T parser *parser respObjMeta interface{} - validationResults []*validationResult + validationResults []*ValidationResult } const textExtractorSubRegexp string = `(.*)` @@ -147,7 +147,7 @@ func (v *responseObject) Validate(iValidators []interface{}, variablesMapping ma if err != nil { return err } - validResult := &validationResult{ + validResult := &ValidationResult{ Validator: Validator{ Check: validator.Check, Expect: expectValue, diff --git a/hrp/runner.go b/hrp/runner.go index 4219e88e..e7505887 100644 --- a/hrp/runner.go +++ b/hrp/runner.go @@ -1,44 +1,21 @@ package hrp import ( - "bufio" - "bytes" - "compress/gzip" - "compress/zlib" "crypto/tls" - _ "embed" "fmt" - "html/template" - "io" - "io/fs" "net" "net/http" - "net/http/httputil" "net/url" - "os" "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" "testing" "time" - "github.com/andybalholm/brotli" - "github.com/jinzhu/copier" - "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/json" "github.com/httprunner/httprunner/hrp/internal/sdk" ) -const ( - summaryPath string = "reports/summary-%v.json" - reportPath string = "reports/report-%v.html" -) - // Run starts to run API test with default configs. func Run(testcases ...ITestCase) error { t := &testing.T{} @@ -156,6 +133,7 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { return err } + // run testcase one by one for _, testcase := range testCases { cfg := testcase.Config // parse config parameters @@ -172,16 +150,17 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { cfg.Variables = mergeVariables(it.Next(), cfg.Variables) } } - caseRunnerObj := r.newCaseRunner(testcase) - if err = caseRunnerObj.run(); err != nil { + sessionRunner := r.NewSessionRunner(testcase) + if err = sessionRunner.Run(); err != nil { log.Error().Err(err).Msg("[Run] run testcase failed") return err } - caseSummary := caseRunnerObj.getSummary() + caseSummary := sessionRunner.getSummary() s.appendCaseSummary(caseSummary) } } s.Time.Duration = time.Since(s.Time.StartAt).Seconds() + // save summary if r.saveTests { dir, _ := filepath.Split(summaryPath) @@ -194,6 +173,7 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { return err } } + // generate HTML report if r.genHTMLReport { err := s.genHTMLReport() @@ -204,933 +184,12 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { return nil } -func loadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) { - testCases := make([]*TestCase, 0) - - for _, iTestCase := range iTestCases { - if _, ok := iTestCase.(*TestCase); ok { - testcase, err := iTestCase.ToTestCase() - if err != nil { - log.Error().Err(err).Msg("failed to convert ITestCase interface to TestCase struct") - return nil, err - } - testCases = append(testCases, testcase) - continue - } - - // iTestCase should be a TestCasePath, file path or folder path - tcPath, ok := iTestCase.(*TestCasePath) - if !ok { - return nil, errors.New("invalid iTestCase type") - } - - casePath := tcPath.GetPath() - err := fs.WalkDir(os.DirFS(casePath), ".", func(path string, dir fs.DirEntry, e error) error { - if dir == nil { - // casePath is a file other than a dir - path = casePath - } else if dir.IsDir() && path != "." && strings.HasPrefix(path, ".") { - // skip hidden folders - return fs.SkipDir - } else { - // casePath is a dir - path = filepath.Join(casePath, path) - } - - // ignore non-testcase files - ext := filepath.Ext(path) - if ext != ".yml" && ext != ".yaml" && ext != ".json" { - return nil - } - - // filtered testcases - testCasePath := TestCasePath(path) - tc, err := testCasePath.ToTestCase() - if err != nil { - log.Error().Err(err).Str("path", path).Msg("load testcase failed") - return errors.Wrap(err, "load testcase failed") - } - testCases = append(testCases, tc) - return nil - }) - if err != nil { - return nil, errors.Wrap(err, "read dir failed") - } - } - - log.Info().Int("count", len(testCases)).Msg("load testcases successfully") - return testCases, nil -} - -func (r *HRPRunner) newCaseRunner(testcase *TestCase) *caseRunner { - caseRunner := &caseRunner{ - TestCase: testcase, +func (r *HRPRunner) NewSessionRunner(testcase *TestCase) *SessionRunner { + sessionRunner := &SessionRunner{ + testCase: testcase, hrpRunner: r, parser: newParser(), summary: newSummary(), } - caseRunner.reset() - return caseRunner -} - -// caseRunner is used to run testcase and its steps. -// each testcase has its own caseRunner instance and share session variables. -type caseRunner struct { - *TestCase - hrpRunner *HRPRunner - parser *parser - sessionVariables map[string]interface{} - // transactions stores transaction timing info. - // key is transaction name, value is map of transaction type and time, e.g. start time and end time. - transactions map[string]map[transactionType]time.Time - startTime time.Time // record start time of the testcase - summary *testCaseSummary // record test case summary -} - -// reset clears runner session variables. -func (r *caseRunner) reset() *caseRunner { - log.Info().Msg("[init] Reset session variables") - r.sessionVariables = make(map[string]interface{}) - r.transactions = make(map[string]map[transactionType]time.Time) - r.startTime = time.Now() - r.summary.Name = r.Config.Name - return r -} - -func (r *caseRunner) run() error { - config := r.TestCase.Config - log.Info().Str("testcase", config.Name).Msg("run testcase start") - // init plugin - var err error - if r.parser.plugin, err = initPlugin(config.Path, r.hrpRunner.pluginLogOn); err != nil { - return err - } - defer func() { - if r.parser.plugin != nil { - r.parser.plugin.Quit() - } - }() - if err := r.parseConfig(config); err != nil { - return err - } - - r.startTime = time.Now() - for index := range r.TestCase.TestSteps { - stepDataObj, err := r.runStep(index, config) - if stepDataObj == nil { - stepDataObj = &stepData{ - Name: r.TestCase.TestSteps[index].Name(), - Success: false, - } - } - if stepDataObj.StepType == stepTypeTestCase { - // merge test case if the step is test case - summary, ok := stepDataObj.Data.(*testCaseSummary) - if ok { - r.summary.Records = append(r.summary.Records, summary.Records...) - r.summary.Stat.Total += summary.Stat.Total - r.summary.Stat.Successes += summary.Stat.Successes - r.summary.Stat.Failures += summary.Stat.Failures - } - } else if stepDataObj.StepType == stepTypeRequest { - // only record that the test step is the request step - r.summary.Records = append(r.summary.Records, stepDataObj) - r.summary.Stat.Total += 1 - if stepDataObj.Success { - r.summary.Stat.Successes += 1 - } else { - r.summary.Stat.Failures += 1 - } - } - r.summary.Success = r.summary.Success && stepDataObj.Success - if err != nil { - stepDataObj.Attachment = err.Error() - if r.hrpRunner.failfast { - return errors.Wrap(err, "abort running due to failfast setting") - } - } - } - - log.Info().Str("testcase", config.Name).Msg("run testcase end") - return nil -} - -func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepData, err error) { - step := r.TestCase.TestSteps[index] - - // step type priority order: transaction > rendezvous > thinktime > testcase > request - if stepTran, ok := step.(*StepTransaction); ok { - // transaction step - return r.runStepTransaction(stepTran.step.Transaction) - } else if stepRend, ok := step.(*StepRendezvous); ok { - // rendezvous step - return r.runStepRendezvous(stepRend.step.Rendezvous) - } else if stepThink, ok := step.(*StepThinkTime); ok { - // think time step - return r.runStepThinkTime(stepThink.step, caseConfig.ThinkTime) - } - - log.Info().Str("step", step.Name()).Msg("run step start") - - // copy step and config to avoid data racing - copiedStep := &TStep{} - if err = copier.Copy(copiedStep, step.ToStruct()); err != nil { - log.Error().Err(err).Msg("copy step data failed") - return nil, err - } - - stepVariables := copiedStep.Variables - // override variables - // step variables > session variables (extracted variables from previous steps) - stepVariables = mergeVariables(stepVariables, r.sessionVariables) - // step variables > testcase config variables - stepVariables = mergeVariables(stepVariables, caseConfig.Variables) - - // parse step variables - parsedVariables, err := r.parser.parseVariables(stepVariables) - if err != nil { - log.Error().Interface("variables", caseConfig.Variables).Err(err).Msg("parse step variables failed") - return nil, err - } - copiedStep.Variables = parsedVariables // avoid data racing - - // step type priority order: testcase > request - if _, ok := step.(*StepTestCaseWithOptionalArgs); ok { - // run referenced testcase - log.Info().Str("testcase", copiedStep.Name).Msg("run referenced testcase") - stepResult, err = r.runStepTestCase(copiedStep) - if err != nil { - log.Error().Err(err).Msg("run referenced testcase step failed") - } - } else { - if _, ok := step.(*StepAPIWithOptionalArgs); ok { - // run referenced API - log.Info().Str("api", copiedStep.Name).Msg("run referenced api") - api, _ := copiedStep.API.(*API) - extendWithAPI(copiedStep, api) - } - // override headers - if caseConfig.Headers != nil { - copiedStep.Request.Headers = mergeMap(copiedStep.Request.Headers, caseConfig.Headers) - } - // parse step request url - var requestUrl interface{} - requestUrl, err = r.parser.parseString(copiedStep.Request.URL, copiedStep.Variables) - if err != nil { - log.Error().Err(err).Msg("parse request url failed") - requestUrl = copiedStep.Variables - } - copiedStep.Request.URL = buildURL(caseConfig.BaseURL, convertString(requestUrl)) // avoid data racing - // run request - stepResult, err = r.runStepRequest(copiedStep) - if err != nil { - log.Error().Err(err).Msg("run request step failed") - } - } - - // update extracted variables - for k, v := range stepResult.ExportVars { - r.sessionVariables[k] = v - } - - log.Info(). - Str("step", step.Name()). - Bool("success", stepResult.Success). - Interface("exportVars", stepResult.ExportVars). - Msg("run step end") - return stepResult, err -} - -func (r *caseRunner) runStepThinkTime(step *TStep, ttc *ThinkTimeConfig) (stepResult *stepData, err error) { - thinkTime := step.ThinkTime - log.Info(). - Str("name", step.Name). - Float64("time", thinkTime.Time). - Msg("think time") - stepResult = &stepData{ - Name: step.Name, - StepType: stepTypeThinkTime, - Success: true, - } - if ttc == nil { - ttc = &ThinkTimeConfig{thinkTimeDefault, nil, 0} - } - var tt time.Duration - switch ttc.Strategy { - case thinkTimeDefault: - tt = time.Duration(thinkTime.Time*1000) * time.Millisecond - case thinkTimeRandomPercentage: - m, ok := ttc.Setting.(map[string]float64) // e.g. {"min_percentage": 0.5, "max_percentage": 1.5} - if !ok { - tt = time.Duration(thinkTime.Time*1000) * time.Millisecond - break - } - res := builtin.GetRandomNumber(int(thinkTime.Time*m["min_percentage"]*1000), int(thinkTime.Time*m["max_percentage"]*1000)) - tt = time.Duration(res) * time.Millisecond - case thinkTimeMultiply: - value, ok := ttc.Setting.(float64) // e.g. 0.5 - if !ok || value <= 0 { - value = thinkTimeDefaultMultiply - } - tt = time.Duration(thinkTime.Time*value*1000) * time.Millisecond - case thinkTimeIgnore: - // nothing to do - } - // no more than limit - if ttc.Limit > 0 { - limit := time.Duration(ttc.Limit*1000) * time.Millisecond - if limit < tt { - tt = limit - } - } - time.Sleep(tt) - return stepResult, nil -} - -func (r *caseRunner) runStepTransaction(transaction *Transaction) (stepResult *stepData, err error) { - log.Info(). - Str("name", transaction.Name). - Str("type", string(transaction.Type)). - Msg("transaction") - - stepResult = &stepData{ - Name: transaction.Name, - StepType: stepTypeTransaction, - Success: true, - Elapsed: 0, - ContentSize: 0, // TODO: record transaction total response length - } - - // create transaction if not exists - if _, ok := r.transactions[transaction.Name]; !ok { - r.transactions[transaction.Name] = make(map[transactionType]time.Time) - } - - // record transaction start time, override if already exists - if transaction.Type == transactionStart { - r.transactions[transaction.Name][transactionStart] = time.Now() - } - // record transaction end time, override if already exists - if transaction.Type == transactionEnd { - r.transactions[transaction.Name][transactionEnd] = time.Now() - - // if transaction start time not exists, use testcase start time instead - if _, ok := r.transactions[transaction.Name][transactionStart]; !ok { - r.transactions[transaction.Name][transactionStart] = r.startTime - } - - // calculate transaction duration - duration := r.transactions[transaction.Name][transactionEnd].Sub( - r.transactions[transaction.Name][transactionStart]) - stepResult.Elapsed = duration.Milliseconds() - log.Info().Str("name", transaction.Name).Dur("elapsed", duration).Msg("transaction") - } - - return stepResult, nil -} - -func (r *caseRunner) runStepRendezvous(rendezvous *Rendezvous) (stepResult *stepData, err error) { - log.Info(). - Str("name", rendezvous.Name). - Float32("percent", rendezvous.Percent). - Int64("number", rendezvous.Number). - Int64("timeout", rendezvous.Timeout). - Msg("rendezvous") - stepResult = &stepData{ - Name: rendezvous.Name, - StepType: stepTypeRendezvous, - Success: true, - } - - // pass current rendezvous if already released, activate rendezvous sequentially after spawn done - if rendezvous.isReleased() || !r.isPreRendezvousAllReleased(rendezvous) || !rendezvous.isSpawnDone() { - return stepResult, nil - } - - // activate the rendezvous only once during each cycle - rendezvous.once.Do(func() { - close(rendezvous.activateChan) - }) - - // check current cnt using double check lock before updating to avoid negative WaitGroup counter - if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { - rendezvous.lock.Lock() - if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { - atomic.AddInt64(&rendezvous.cnt, 1) - rendezvous.wg.Done() - rendezvous.timerResetChan <- struct{}{} - } - rendezvous.lock.Unlock() - } - - // block until current rendezvous released - <-rendezvous.releaseChan - return stepResult, nil -} - -func (r *caseRunner) isPreRendezvousAllReleased(rendezvous *Rendezvous) bool { - tCase := r.TestCase.ToTCase() - for _, step := range tCase.TestSteps { - preRendezvous := step.Rendezvous - if preRendezvous == nil { - continue - } - // meet current rendezvous, all previous rendezvous released, return true - if preRendezvous == rendezvous { - return true - } - if !preRendezvous.isReleased() { - return false - } - } - return true -} - -func (r *Rendezvous) reset() { - r.cnt = 0 - r.releasedFlag = 0 - r.wg.Add(int(r.Number)) - // timerResetChan channel will not be closed, thus init only once - if r.timerResetChan == nil { - r.timerResetChan = make(chan struct{}) - } - r.activateChan = make(chan struct{}) - r.releaseChan = make(chan struct{}) - r.once = new(sync.Once) -} - -func (r *Rendezvous) isSpawnDone() bool { - return atomic.LoadUint32(&r.spawnDoneFlag) == 1 -} - -func (r *Rendezvous) setSpawnDone() { - atomic.StoreUint32(&r.spawnDoneFlag, 1) -} - -func (r *Rendezvous) isReleased() bool { - return atomic.LoadUint32(&r.releasedFlag) == 1 -} - -func (r *Rendezvous) setReleased() { - atomic.StoreUint32(&r.releasedFlag, 1) -} - -func initRendezvous(testcase *TestCase, total int64) []*Rendezvous { - tCase := testcase.ToTCase() - var rendezvousList []*Rendezvous - for _, step := range tCase.TestSteps { - if step.Rendezvous == nil { - continue - } - rendezvous := step.Rendezvous - - // either number or percent should be correctly put, otherwise set to default (total) - if rendezvous.Number == 0 && rendezvous.Percent > 0 && rendezvous.Percent <= defaultRendezvousPercent { - rendezvous.Number = int64(rendezvous.Percent * float32(total)) - } else if rendezvous.Number > 0 && rendezvous.Number <= total && rendezvous.Percent == 0 { - rendezvous.Percent = float32(rendezvous.Number) / float32(total) - } else { - log.Warn(). - Str("name", rendezvous.Name). - Int64("default number", total). - Float32("default percent", defaultRendezvousPercent). - Msg("rendezvous parameter not defined or error, set to default value") - rendezvous.Number = total - rendezvous.Percent = defaultRendezvousPercent - } - - if rendezvous.Timeout <= 0 { - rendezvous.Timeout = defaultRendezvousTimeout - } - - rendezvous.reset() - rendezvousList = append(rendezvousList, rendezvous) - } - return rendezvousList -} - -func waitRendezvous(rendezvousList []*Rendezvous) { - if rendezvousList != nil { - lastRendezvous := rendezvousList[len(rendezvousList)-1] - for _, rendezvous := range rendezvousList { - go waitSingleRendezvous(rendezvous, rendezvousList, lastRendezvous) - } - } -} - -func waitSingleRendezvous(rendezvous *Rendezvous, rendezvousList []*Rendezvous, lastRendezvous *Rendezvous) { - for { - // cycle start: block current checking until current rendezvous activated - <-rendezvous.activateChan - stop := make(chan struct{}) - timeout := time.Duration(rendezvous.Timeout) * time.Millisecond - timer := time.NewTimer(timeout) - go func() { - defer close(stop) - rendezvous.wg.Wait() - }() - for !rendezvous.isReleased() { - select { - case <-rendezvous.timerResetChan: - timer.Reset(timeout) - case <-stop: - rendezvous.setReleased() - close(rendezvous.releaseChan) - log.Info(). - Str("name", rendezvous.Name). - Float32("percent", rendezvous.Percent). - Int64("number", rendezvous.Number). - Int64("timeout(ms)", rendezvous.Timeout). - Int64("cnt", rendezvous.cnt). - Str("reason", "rendezvous release condition satisfied"). - Msg("rendezvous released") - case <-timer.C: - rendezvous.setReleased() - close(rendezvous.releaseChan) - log.Info(). - Str("name", rendezvous.Name). - Float32("percent", rendezvous.Percent). - Int64("number", rendezvous.Number). - Int64("timeout(ms)", rendezvous.Timeout). - Int64("cnt", rendezvous.cnt). - Str("reason", "time's up"). - Msg("rendezvous released") - } - } - // cycle end: reset all previous rendezvous after last rendezvous released - // otherwise, block current checker until the last rendezvous end - if rendezvous == lastRendezvous { - for _, r := range rendezvousList { - r.reset() - } - } else { - <-lastRendezvous.releaseChan - } - } -} - -func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err error) { - stepResult = &stepData{ - Name: step.Name, - StepType: stepTypeRequest, - Success: false, - ContentSize: 0, - } - sessionData := newSessionData() - - // convert request struct to map - jsonRequest, _ := json.Marshal(&step.Request) - var requestMap map[string]interface{} - _ = json.Unmarshal(jsonRequest, &requestMap) - - rawUrl := step.Request.URL - method := step.Request.Method - req := &http.Request{ - Method: method, - Header: make(http.Header), - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - } - - // prepare request headers - if len(step.Request.Headers) > 0 { - headers, err := r.parser.parseHeaders(step.Request.Headers, step.Variables) - if err != nil { - return stepResult, errors.Wrap(err, "parse headers failed") - } - for key, value := range headers { - // omit pseudo header names for HTTP/1, e.g. :authority, :method, :path, :scheme - if strings.HasPrefix(key, ":") { - continue - } - req.Header.Add(key, value) - - // prepare content length - if strings.EqualFold(key, "Content-Length") && value != "" { - if l, err := strconv.ParseInt(value, 10, 64); err == nil { - req.ContentLength = l - } - } - } - } - - // prepare request params - var queryParams url.Values - if len(step.Request.Params) > 0 { - params, err := r.parser.parseData(step.Request.Params, step.Variables) - if err != nil { - return stepResult, errors.Wrap(err, "parse request params failed") - } - parsedParams := params.(map[string]interface{}) - requestMap["params"] = parsedParams - if len(parsedParams) > 0 { - queryParams = make(url.Values) - for k, v := range parsedParams { - queryParams.Add(k, fmt.Sprint(v)) - } - } - } - if queryParams != nil { - // append params to url - paramStr := queryParams.Encode() - if strings.IndexByte(rawUrl, '?') == -1 { - rawUrl = rawUrl + "?" + paramStr - } else { - rawUrl = rawUrl + "&" + paramStr - } - } - - // prepare request cookies - for cookieName, cookieValue := range step.Request.Cookies { - value, err := r.parser.parseData(cookieValue, step.Variables) - if err != nil { - return stepResult, errors.Wrap(err, "parse cookie value failed") - } - req.AddCookie(&http.Cookie{ - Name: cookieName, - Value: fmt.Sprintf("%v", value), - }) - } - - // prepare request body - if step.Request.Body != nil { - data, err := r.parser.parseData(step.Request.Body, step.Variables) - if err != nil { - return stepResult, err - } - // check request body format if Content-Type specified as application/json - if strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") { - switch data.(type) { - case bool, float64, string, map[string]interface{}, []interface{}, nil: - break - default: - return stepResult, errors.Errorf("request body type inconsistent with Content-Type: %v", req.Header.Get("Content-Type")) - } - } - requestMap["body"] = data - var dataBytes []byte - switch vv := data.(type) { - case map[string]interface{}: - contentType := req.Header.Get("Content-Type") - if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { - // post form data - formData := make(url.Values) - for k, v := range vv { - formData.Add(k, fmt.Sprint(v)) - } - dataBytes = []byte(formData.Encode()) - } else { - // post json - dataBytes, err = json.Marshal(vv) - if err != nil { - return stepResult, err - } - if contentType == "" { - req.Header.Set("Content-Type", "application/json; charset=utf-8") - } - } - case []interface{}: - contentType := req.Header.Get("Content-Type") - // post json - dataBytes, err = json.Marshal(vv) - if err != nil { - return stepResult, err - } - if contentType == "" { - req.Header.Set("Content-Type", "application/json; charset=utf-8") - } - case string: - dataBytes = []byte(vv) - case []byte: - dataBytes = vv - case bytes.Buffer: - dataBytes = vv.Bytes() - default: // unexpected body type - return stepResult, errors.New("unexpected request body type") - } - setBodyBytes(req, dataBytes) - } - // update header - headers := make(map[string]string) - for key, value := range req.Header { - headers[key] = value[0] - } - requestMap["headers"] = headers - - // prepare url - u, err := url.Parse(rawUrl) - if err != nil { - return stepResult, errors.Wrap(err, "parse url failed") - } - req.URL = u - req.Host = u.Host - - // add request object to step variables, could be used in setup hooks - step.Variables["hrp_step_name"] = step.Name - step.Variables["hrp_step_request"] = requestMap - - // deal with setup hooks - for _, setupHook := range step.SetupHooks { - _, err = r.parser.parseData(setupHook, step.Variables) - if err != nil { - return stepResult, errors.Wrap(err, "run setup hooks failed") - } - } - - // log & print request - if err := r.printRequest(req); err != nil { - return stepResult, err - } - - // do request action - start := time.Now() - resp, err := r.hrpRunner.client.Do(req) - stepResult.Elapsed = time.Since(start).Milliseconds() - if err != nil { - return stepResult, errors.Wrap(err, "do request failed") - } - defer resp.Body.Close() - - // decode response body in br/gzip/deflate formats - err = decodeResponseBody(resp) - if err != nil { - return stepResult, errors.Wrap(err, "decode response body failed") - } - - // log & print response - if err := r.printResponse(resp); err != nil { - return stepResult, err - } - - // new response object - respObj, err := newResponseObject(r.hrpRunner.t, r.parser, resp) - if err != nil { - err = errors.Wrap(err, "init ResponseObject error") - return - } - - // add response object to step variables, could be used in teardown hooks - step.Variables["hrp_step_response"] = respObj.respObjMeta - - // deal with teardown hooks - for _, teardownHook := range step.TeardownHooks { - _, err = r.parser.parseData(teardownHook, step.Variables) - if err != nil { - return stepResult, errors.Wrap(err, "run teardown hooks failed") - } - } - - sessionData.ReqResps.Request = requestMap - sessionData.ReqResps.Response = builtin.FormatResponse(respObj.respObjMeta) - - // extract variables from response - extractors := step.Extract - extractMapping := respObj.Extract(extractors) - stepResult.ExportVars = extractMapping - - // override step variables with extracted variables - stepVariables := mergeVariables(step.Variables, extractMapping) - - // validate response - err = respObj.Validate(step.Validators, stepVariables) - sessionData.Validators = respObj.validationResults - if err == nil { - sessionData.Success = true - stepResult.Success = true - } - stepResult.ContentSize = resp.ContentLength - stepResult.Data = sessionData - - return stepResult, err -} - -func (r *caseRunner) printRequest(req *http.Request) error { - if !r.hrpRunner.requestsLogOn { - return nil - } - reqContentType := req.Header.Get("Content-Type") - printBody := shouldPrintBody(reqContentType) - reqDump, err := httputil.DumpRequest(req, printBody) - if err != nil { - return errors.Wrap(err, "dump request failed") - } - fmt.Println("-------------------- request --------------------") - reqContent := string(reqDump) - if req.Body != nil && !printBody { - reqContent += fmt.Sprintf("(request body omitted for Content-Type: %v)", reqContentType) - } - fmt.Println(reqContent) - return nil -} - -func (r *caseRunner) printResponse(resp *http.Response) error { - if !r.hrpRunner.requestsLogOn { - return nil - } - fmt.Println("==================== response ===================") - respContentType := resp.Header.Get("Content-Type") - printBody := shouldPrintBody(respContentType) - respDump, err := httputil.DumpResponse(resp, printBody) - if err != nil { - return errors.Wrap(err, "dump response failed") - } - respContent := string(respDump) - if !printBody { - respContent += fmt.Sprintf("(response body omitted for Content-Type: %v)", respContentType) - } - fmt.Println(respContent) - fmt.Println("--------------------------------------------------") - return nil -} - -// shouldPrintBody return true if the Content-Type is printable -// including text/*, application/json, application/xml, application/www-form-urlencoded -func shouldPrintBody(contentType string) bool { - if strings.HasPrefix(contentType, "text/") { - return true - } - if strings.HasPrefix(contentType, "application/json") { - return true - } - if strings.HasPrefix(contentType, "application/xml") { - return true - } - if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { - return true - } - return false -} - -func decodeResponseBody(resp *http.Response) (err error) { - switch resp.Header.Get("Content-Encoding") { - case "br": - resp.Body = io.NopCloser(brotli.NewReader(resp.Body)) - case "gzip": - resp.Body, err = gzip.NewReader(resp.Body) - if err != nil { - return err - } - resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched - case "deflate": - resp.Body, err = zlib.NewReader(resp.Body) - if err != nil { - return err - } - resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched - } - return nil -} - -func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err error) { - stepResult = &stepData{ - Name: step.Name, - StepType: stepTypeTestCase, - Success: false, - } - testcase := step.TestCase.(*TestCase) - - // copy testcase to avoid data racing - copiedTestCase := &TestCase{} - if err = copier.Copy(copiedTestCase, testcase); err != nil { - log.Error().Err(err).Msg("copy testcase failed") - return stepResult, err - } - // override testcase config - extendWithTestCase(step, copiedTestCase) - - start := time.Now() - caseRunnerObj := r.hrpRunner.newCaseRunner(copiedTestCase) - err = caseRunnerObj.run() - stepResult.Elapsed = time.Since(start).Milliseconds() - if err != nil { - return stepResult, err - } - stepResult.Data = caseRunnerObj.getSummary() - // export testcase export variables - stepResult.ExportVars = caseRunnerObj.summary.InOut.ExportVars - stepResult.Success = true - return stepResult, nil -} - -func (r *caseRunner) parseConfig(cfg *TConfig) error { - // parse config variables - parsedVariables, err := r.parser.parseVariables(cfg.Variables) - if err != nil { - log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed") - return err - } - cfg.Variables = parsedVariables - - // parse config name - parsedName, err := r.parser.parseString(cfg.Name, cfg.Variables) - if err != nil { - return err - } - cfg.Name = convertString(parsedName) - - // parse config base url - parsedBaseURL, err := r.parser.parseString(cfg.BaseURL, cfg.Variables) - if err != nil { - return err - } - cfg.BaseURL = convertString(parsedBaseURL) - - // ensure correction of think time config - cfg.ThinkTime.checkThinkTime() - - return nil -} - -func newSummary() *testCaseSummary { - return &testCaseSummary{ - Success: true, - Stat: &testStepStat{}, - Time: &testCaseTime{}, - InOut: &testCaseInOut{}, - } -} - -func (r *caseRunner) getSummary() *testCaseSummary { - caseSummary := r.summary - caseSummary.Time.StartAt = r.startTime - caseSummary.Time.Duration = time.Since(r.startTime).Seconds() - exportVars := make(map[string]interface{}) - for _, value := range r.Config.Export { - exportVars[value] = r.sessionVariables[value] - } - caseSummary.InOut.ExportVars = exportVars - caseSummary.InOut.ConfigVars = r.Config.Variables - return caseSummary -} - -func setBodyBytes(req *http.Request, data []byte) { - req.Body = io.NopCloser(bytes.NewReader(data)) - req.ContentLength = int64(len(data)) -} - -//go:embed internal/scaffold/templates/report/template.html -var reportTemplate string - -func (s *Summary) genHTMLReport() error { - dir, _ := filepath.Split(reportPath) - err := builtin.EnsureFolderExists(dir) - if err != nil { - return err - } - file, err := os.OpenFile(fmt.Sprintf(reportPath, s.Time.StartAt.Unix()), os.O_WRONLY|os.O_CREATE, 0666) - if err != nil { - log.Error().Err(err).Msg("open file failed") - return err - } - defer file.Close() - writer := bufio.NewWriter(file) - tmpl := template.Must(template.New("report").Parse(reportTemplate)) - err = tmpl.Execute(writer, s) - if err != nil { - log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") - return err - } - err = writer.Flush() - return err + return sessionRunner } diff --git a/hrp/runner_test.go b/hrp/runner_test.go index 98060d7a..df09eb88 100644 --- a/hrp/runner_test.go +++ b/hrp/runner_test.go @@ -1,16 +1,14 @@ package hrp import ( - "math" "os" "os/exec" "testing" "time" + "github.com/httprunner/httprunner/hrp/internal/scaffold" "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" - - "github.com/httprunner/httprunner/hrp/internal/scaffold" ) func buildHashicorpGoPlugin() { @@ -97,71 +95,6 @@ func assertRunTestCases(t *testing.T) { } } -func TestRunCaseWithRendezvous(t *testing.T) { - rendezvousBoundaryTestcase := &TestCase{ - Config: NewConfig("run request with functions"). - SetBaseURL("https://postman-echo.com"). - WithVariables(map[string]interface{}{ - "n": 5, - "a": 12.3, - "b": 3.45, - }), - TestSteps: []IStep{ - NewStep("test negative number"). - Rendezvous("test negative number"). - WithUserNumber(-1), - NewStep("test overflow number"). - Rendezvous("test overflow number"). - WithUserNumber(1000000), - NewStep("test negative percent"). - Rendezvous("test very low percent"). - WithUserPercent(-0.5), - NewStep("test very low percent"). - Rendezvous("test very low percent"). - WithUserPercent(0.00001), - NewStep("test overflow percent"). - Rendezvous("test overflow percent"). - WithUserPercent(1.5), - NewStep("test conflict params"). - Rendezvous("test conflict params"). - WithUserNumber(1). - WithUserPercent(0.123), - NewStep("test negative timeout"). - Rendezvous("test negative timeout"). - WithTimeout(-1000), - }, - } - - type rendezvousParam struct { - number int64 - percent float32 - timeout int64 - } - expectedRendezvousParams := []rendezvousParam{ - {number: 100, percent: 1, timeout: 5000}, - {number: 100, percent: 1, timeout: 5000}, - {number: 100, percent: 1, timeout: 5000}, - {number: 0, percent: 0.00001, timeout: 5000}, - {number: 100, percent: 1, timeout: 5000}, - {number: 100, percent: 1, timeout: 5000}, - {number: 100, percent: 1, timeout: 5000}, - } - - rendezvousList := initRendezvous(rendezvousBoundaryTestcase, 100) - - for i, r := range rendezvousList { - if r.Number != expectedRendezvousParams[i].number { - t.Fatalf("run rendezvous %v error: expected number: %v, real number: %v", r.Name, expectedRendezvousParams[i].number, r.Number) - } - if math.Abs(float64(r.Percent-expectedRendezvousParams[i].percent)) > 0.001 { - t.Fatalf("run rendezvous %v error: expected percent: %v, real percent: %v", r.Name, expectedRendezvousParams[i].percent, r.Percent) - } - if r.Timeout != expectedRendezvousParams[i].timeout { - t.Fatalf("run rendezvous %v error: expected timeout: %v, real timeout: %v", r.Name, expectedRendezvousParams[i].timeout, r.Timeout) - } - } -} - func TestRunCaseWithThinkTime(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() @@ -220,27 +153,6 @@ func TestRunCaseWithThinkTime(t *testing.T) { } } -func TestGenHTMLReport(t *testing.T) { - summary := newOutSummary() - caseSummary1 := newSummary() - caseSummary2 := newSummary() - stepResult1 := &stepData{} - stepResult2 := &stepData{ - Name: "Test", - StepType: stepTypeRequest, - Success: false, - ContentSize: 0, - Attachment: "err", - } - caseSummary1.Records = []*stepData{stepResult1, stepResult2, nil} - summary.appendCaseSummary(caseSummary1) - summary.appendCaseSummary(caseSummary2) - err := summary.genHTMLReport() - if err != nil { - t.Error(err) - } -} - func TestRunCaseWithPluginJSON(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() diff --git a/hrp/session.go b/hrp/session.go new file mode 100644 index 00000000..84e293e5 --- /dev/null +++ b/hrp/session.go @@ -0,0 +1,150 @@ +package hrp + +import ( + _ "embed" + "time" + + "github.com/jinzhu/copier" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// SessionRunner is used to run testcase and its steps. +// each testcase has its own SessionRunner instance and share session variables. +type SessionRunner struct { + testCase *TestCase + hrpRunner *HRPRunner + parser *parser + sessionVariables map[string]interface{} + // transactions stores transaction timing info. + // key is transaction name, value is map of transaction type and time, e.g. start time and end time. + transactions map[string]map[transactionType]time.Time + startTime time.Time // record start time of the testcase + summary *TestCaseSummary // record test case summary +} + +func (r *SessionRunner) init() { + log.Info().Msg("init session runner") + r.sessionVariables = make(map[string]interface{}) + r.transactions = make(map[string]map[transactionType]time.Time) + r.startTime = time.Now() + r.summary.Name = r.testCase.Config.Name +} + +// Run runs the test steps in sequential order. +func (r *SessionRunner) Run() error { + config := r.testCase.Config + log.Info().Str("testcase", config.Name).Msg("run testcase start") + + // init session runner + r.init() + + // init plugin + var err error + if r.parser.plugin, err = initPlugin(config.Path, r.hrpRunner.pluginLogOn); err != nil { + return err + } + defer func() { + if r.parser.plugin != nil { + r.parser.plugin.Quit() + } + }() + + // parse config + if err := r.parseConfig(config); err != nil { + return err + } + + r.startTime = time.Now() + // run step in sequential order + for _, step := range r.testCase.TestSteps { + _, err := step.Run(r) + if err != nil && r.hrpRunner.failfast { + return errors.Wrap(err, "abort running due to failfast setting") + } + } + + log.Info().Str("testcase", config.Name).Msg("run testcase end") + return nil +} + +func (r *SessionRunner) overrideVariables(step *TStep) (*TStep, error) { + // copy step and config to avoid data racing + copiedStep := &TStep{} + if err := copier.Copy(copiedStep, step); err != nil { + log.Error().Err(err).Msg("copy step data failed") + return nil, err + } + + stepVariables := copiedStep.Variables + // override variables + // step variables > session variables (extracted variables from previous steps) + stepVariables = mergeVariables(stepVariables, r.sessionVariables) + // step variables > testcase config variables + stepVariables = mergeVariables(stepVariables, r.testCase.Config.Variables) + + // parse step variables + parsedVariables, err := r.parser.parseVariables(stepVariables) + if err != nil { + log.Error().Interface("variables", r.testCase.Config.Variables).Err(err).Msg("parse step variables failed") + return nil, err + } + copiedStep.Variables = parsedVariables // avoid data racing + return copiedStep, nil +} + +func (r *SessionRunner) overrideConfig(step *TStep) { + // override headers + if r.testCase.Config.Headers != nil { + step.Request.Headers = mergeMap(step.Request.Headers, r.testCase.Config.Headers) + } + // parse step request url + requestUrl, err := r.parser.parseString(step.Request.URL, step.Variables) + if err != nil { + log.Error().Err(err).Msg("parse request url failed") + requestUrl = step.Variables + } + step.Request.URL = buildURL(r.testCase.Config.BaseURL, convertString(requestUrl)) // avoid data racing +} + +func (r *SessionRunner) parseConfig(cfg *TConfig) error { + // parse config variables + parsedVariables, err := r.parser.parseVariables(cfg.Variables) + if err != nil { + log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed") + return err + } + cfg.Variables = parsedVariables + + // parse config name + parsedName, err := r.parser.parseString(cfg.Name, cfg.Variables) + if err != nil { + return err + } + cfg.Name = convertString(parsedName) + + // parse config base url + parsedBaseURL, err := r.parser.parseString(cfg.BaseURL, cfg.Variables) + if err != nil { + return err + } + cfg.BaseURL = convertString(parsedBaseURL) + + // ensure correction of think time config + cfg.ThinkTimeSetting.checkThinkTime() + + return nil +} + +func (r *SessionRunner) getSummary() *TestCaseSummary { + caseSummary := r.summary + caseSummary.Time.StartAt = r.startTime + caseSummary.Time.Duration = time.Since(r.startTime).Seconds() + exportVars := make(map[string]interface{}) + for _, value := range r.testCase.Config.Export { + exportVars[value] = r.sessionVariables[value] + } + caseSummary.InOut.ExportVars = exportVars + caseSummary.InOut.ConfigVars = r.testCase.Config.Variables + return caseSummary +} diff --git a/hrp/step.go b/hrp/step.go index 72da0818..be47096d 100644 --- a/hrp/step.go +++ b/hrp/step.go @@ -1,480 +1,52 @@ package hrp -import ( - "fmt" - "os" +type StepType string - "github.com/rs/zerolog/log" +const ( + stepTypeRequest StepType = "request" + stepTypeAPI StepType = "api" + stepTypeTestCase StepType = "testcase" + stepTypeTransaction StepType = "transaction" + stepTypeRendezvous StepType = "rendezvous" + stepTypeThinkTime StepType = "thinktime" ) -// NewConfig returns a new constructed testcase config with specified testcase name. -func NewConfig(name string) *TConfig { - return &TConfig{ - Name: name, - Variables: make(map[string]interface{}), - } -} - -// WithVariables sets variables for current testcase. -func (c *TConfig) WithVariables(variables map[string]interface{}) *TConfig { - c.Variables = variables - return c -} - -// SetBaseURL sets base URL for current testcase. -func (c *TConfig) SetBaseURL(baseURL string) *TConfig { - c.BaseURL = baseURL - return c -} - -// SetHeaders sets global headers for current testcase. -func (c *TConfig) SetHeaders(headers map[string]string) *TConfig { - c.Headers = headers - return c -} - -// SetVerifySSL sets whether to verify SSL for current testcase. -func (c *TConfig) SetVerifySSL(verify bool) *TConfig { - c.Verify = verify - return c -} - -// WithParameters sets parameters for current testcase. -func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig { - c.Parameters = parameters - return c -} - -// SetThinkTime sets think time config for current testcase. -func (c *TConfig) SetThinkTime(strategy string, cfg interface{}, limit float64) *TConfig { - c.ThinkTime = &ThinkTimeConfig{strategy, cfg, limit} - return c -} - -// ExportVars specifies variable names to export for current testcase. -func (c *TConfig) ExportVars(vars ...string) *TConfig { - c.Export = vars - return c -} - -// SetWeight sets weight for current testcase, which is used in load testing. -func (c *TConfig) SetWeight(weight int) *TConfig { - c.Weight = weight - return c -} - -// NewStep returns a new constructed teststep with specified step name. -func NewStep(name string) *StepRequest { - return &StepRequest{ - step: &TStep{ - Name: name, - Variables: make(map[string]interface{}), - }, - } -} - -type StepRequest struct { - step *TStep -} - -// WithVariables sets variables for current teststep. -func (s *StepRequest) WithVariables(variables map[string]interface{}) *StepRequest { - s.step.Variables = variables - return s -} - -// SetupHook adds a setup hook for current teststep. -func (s *StepRequest) SetupHook(hook string) *StepRequest { - s.step.SetupHooks = append(s.step.SetupHooks, hook) - return s -} - -// GET makes a HTTP GET request. -func (s *StepRequest) GET(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpGET, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// HEAD makes a HTTP HEAD request. -func (s *StepRequest) HEAD(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpHEAD, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// POST makes a HTTP POST request. -func (s *StepRequest) POST(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpPOST, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// PUT makes a HTTP PUT request. -func (s *StepRequest) PUT(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpPUT, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// DELETE makes a HTTP DELETE request. -func (s *StepRequest) DELETE(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpDELETE, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// OPTIONS makes a HTTP OPTIONS request. -func (s *StepRequest) OPTIONS(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpOPTIONS, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// PATCH makes a HTTP PATCH request. -func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs { - s.step.Request = &Request{ - Method: httpPATCH, - URL: url, - } - return &StepRequestWithOptionalArgs{ - step: s.step, - } -} - -// CallRefCase calls a referenced testcase. -func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs { - var err error - s.step.TestCase, err = tc.ToTestCase() - if err != nil { - log.Error().Err(err).Msg("failed to load testcase") - os.Exit(1) - } - return &StepTestCaseWithOptionalArgs{ - step: s.step, - } -} - -// CallRefAPI calls a referenced api. -func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs { - var err error - s.step.API, err = api.ToAPI() - if err != nil { - log.Error().Err(err).Msg("failed to load api") - os.Exit(1) - } - return &StepAPIWithOptionalArgs{ - step: s.step, - } -} - -// StartTransaction starts a transaction. -func (s *StepRequest) StartTransaction(name string) *StepTransaction { - s.step.Transaction = &Transaction{ - Name: name, - Type: transactionStart, - } - return &StepTransaction{ - step: s.step, - } -} - -// EndTransaction ends a transaction. -func (s *StepRequest) EndTransaction(name string) *StepTransaction { - s.step.Transaction = &Transaction{ - Name: name, - Type: transactionEnd, - } - return &StepTransaction{ - step: s.step, - } -} - -// SetThinkTime sets think time. -func (s *StepRequest) SetThinkTime(time float64) *StepThinkTime { - s.step.ThinkTime = &ThinkTime{ - Time: time, - } - return &StepThinkTime{ - step: s.step, - } -} - -// StepRequestWithOptionalArgs implements IStep interface. -type StepRequestWithOptionalArgs struct { - step *TStep -} - -// SetVerify sets whether to verify SSL for current HTTP request. -func (s *StepRequestWithOptionalArgs) SetVerify(verify bool) *StepRequestWithOptionalArgs { - s.step.Request.Verify = verify - return s -} - -// SetTimeout sets timeout for current HTTP request. -func (s *StepRequestWithOptionalArgs) SetTimeout(timeout float32) *StepRequestWithOptionalArgs { - s.step.Request.Timeout = timeout - return s -} - -// SetProxies sets proxies for current HTTP request. -func (s *StepRequestWithOptionalArgs) SetProxies(proxies map[string]string) *StepRequestWithOptionalArgs { - // TODO - return s -} - -// SetAllowRedirects sets whether to allow redirects for current HTTP request. -func (s *StepRequestWithOptionalArgs) SetAllowRedirects(allowRedirects bool) *StepRequestWithOptionalArgs { - s.step.Request.AllowRedirects = allowRedirects - return s -} - -// SetAuth sets auth for current HTTP request. -func (s *StepRequestWithOptionalArgs) SetAuth(auth map[string]string) *StepRequestWithOptionalArgs { - // TODO - return s -} - -// WithParams sets HTTP request params for current step. -func (s *StepRequestWithOptionalArgs) WithParams(params map[string]interface{}) *StepRequestWithOptionalArgs { - s.step.Request.Params = params - return s -} - -// WithHeaders sets HTTP request headers for current step. -func (s *StepRequestWithOptionalArgs) WithHeaders(headers map[string]string) *StepRequestWithOptionalArgs { - s.step.Request.Headers = headers - return s -} - -// WithCookies sets HTTP request cookies for current step. -func (s *StepRequestWithOptionalArgs) WithCookies(cookies map[string]string) *StepRequestWithOptionalArgs { - s.step.Request.Cookies = cookies - return s -} - -// WithBody sets HTTP request body for current step. -func (s *StepRequestWithOptionalArgs) WithBody(body interface{}) *StepRequestWithOptionalArgs { - s.step.Request.Body = body - return s -} - -// TeardownHook adds a teardown hook for current teststep. -func (s *StepRequestWithOptionalArgs) TeardownHook(hook string) *StepRequestWithOptionalArgs { - s.step.TeardownHooks = append(s.step.TeardownHooks, hook) - return s -} - -// Validate switches to step validation. -func (s *StepRequestWithOptionalArgs) Validate() *StepRequestValidation { - return &StepRequestValidation{ - step: s.step, - } -} - -// Extract switches to step extraction. -func (s *StepRequestWithOptionalArgs) Extract() *StepRequestExtraction { - s.step.Extract = make(map[string]string) - return &StepRequestExtraction{ - step: s.step, - } -} - -func (s *StepRequestWithOptionalArgs) Name() string { - if s.step.Name != "" { - return s.step.Name - } - return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL) -} - -func (s *StepRequestWithOptionalArgs) Type() string { - return fmt.Sprintf("request-%v", s.step.Request.Method) -} - -func (s *StepRequestWithOptionalArgs) ToStruct() *TStep { - return s.step -} - -// StepAPIWithOptionalArgs implements IStep interface. -type StepAPIWithOptionalArgs struct { - step *TStep -} - -// TeardownHook adds a teardown hook for current teststep. -func (s *StepAPIWithOptionalArgs) TeardownHook(hook string) *StepAPIWithOptionalArgs { - s.step.TeardownHooks = append(s.step.TeardownHooks, hook) - return s -} - -// Export specifies variable names to export from referenced api for current step. -func (s *StepAPIWithOptionalArgs) Export(names ...string) *StepAPIWithOptionalArgs { - api, ok := s.step.API.(*API) - if ok { - s.step.Export = append(api.Export, names...) - } - return s -} - -func (s *StepAPIWithOptionalArgs) Name() string { - if s.step.Name != "" { - return s.step.Name - } - api, ok := s.step.API.(*API) - if ok { - return api.Name - } - return "" -} - -func (s *StepAPIWithOptionalArgs) Type() string { - return "api" -} - -func (s *StepAPIWithOptionalArgs) ToStruct() *TStep { - return s.step -} - -// StepTestCaseWithOptionalArgs implements IStep interface. -type StepTestCaseWithOptionalArgs struct { - step *TStep -} - -// TeardownHook adds a teardown hook for current teststep. -func (s *StepTestCaseWithOptionalArgs) TeardownHook(hook string) *StepTestCaseWithOptionalArgs { - s.step.TeardownHooks = append(s.step.TeardownHooks, hook) - return s -} - -// Export specifies variable names to export from referenced testcase for current step. -func (s *StepTestCaseWithOptionalArgs) Export(names ...string) *StepTestCaseWithOptionalArgs { - s.step.Export = append(s.step.Export, names...) - return s -} - -func (s *StepTestCaseWithOptionalArgs) Name() string { - if s.step.Name != "" { - return s.step.Name - } - ts, ok := s.step.TestCase.(*TestCase) - if ok { - return ts.Config.Name - } - return "" -} - -func (s *StepTestCaseWithOptionalArgs) Type() string { - return "testcase" -} - -func (s *StepTestCaseWithOptionalArgs) ToStruct() *TStep { - return s.step -} - -// StepThinkTime implements IStep interface. -type StepThinkTime struct { - step *TStep -} - -func (s *StepThinkTime) Name() string { - return s.step.Name -} - -func (s *StepThinkTime) Type() string { - return "thinktime" -} - -func (s *StepThinkTime) ToStruct() *TStep { - return s.step -} - -// StepTransaction implements IStep interface. -type StepTransaction struct { - step *TStep -} - -func (s *StepTransaction) Name() string { - if s.step.Name != "" { - return s.step.Name - } - return fmt.Sprintf("transaction %s %s", s.step.Transaction.Name, s.step.Transaction.Type) -} - -func (s *StepTransaction) Type() string { - return "transaction" -} - -func (s *StepTransaction) ToStruct() *TStep { - return s.step -} - -// StepRendezvous implements IStep interface. -type StepRendezvous struct { - step *TStep -} - -func (s *StepRendezvous) Name() string { - if s.step.Name != "" { - return s.step.Name - } - return s.step.Rendezvous.Name -} - -func (s *StepRendezvous) Type() string { - return "rendezvous" -} - -func (s *StepRendezvous) ToStruct() *TStep { - return s.step -} - -// Rendezvous creates a new rendezvous -func (s *StepRequest) Rendezvous(name string) *StepRendezvous { - s.step.Rendezvous = &Rendezvous{ - Name: name, - } - return &StepRendezvous{ - step: s.step, - } -} - -// WithUserNumber sets the user number needed to release the current rendezvous -func (s *StepRendezvous) WithUserNumber(number int64) *StepRendezvous { - s.step.Rendezvous.Number = number - return s -} - -// WithUserPercent sets the user percent needed to release the current rendezvous -func (s *StepRendezvous) WithUserPercent(percent float32) *StepRendezvous { - s.step.Rendezvous.Percent = percent - return s -} - -// WithTimeout sets the timeout of duration between each user arriving at the current rendezvous -func (s *StepRendezvous) WithTimeout(timeout int64) *StepRendezvous { - s.step.Rendezvous.Timeout = timeout - return s +type StepResult struct { + Name string `json:"name" yaml:"name"` // step name + StepType StepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous + Success bool `json:"success" yaml:"success"` // step execution result + Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms) + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` // session data or slice of step data + ContentSize int64 `json:"content_size" yaml:"content_size"` // response body length + ExportVars map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables + Attachment string `json:"attachment,omitempty" yaml:"attachment,omitempty"` // step error information +} + +// TStep represents teststep data structure. +// Each step maybe three different types: make one request or reference another api/testcase. +type TStep struct { + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,omitempty"` + API interface{} `json:"api,omitempty" yaml:"api,omitempty"` // *APIPath or *API + TestCase interface{} `json:"testcase,omitempty" yaml:"testcase,omitempty"` // *TestCasePath or *TestCase + Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"` + Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,omitempty"` + ThinkTime *ThinkTime `json:"think_time,omitempty" yaml:"think_time,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` + TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` + Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` + Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` +} + +// IStep represents interface for all types for teststeps, includes: +// StepRequest, StepRequestWithOptionalArgs, StepRequestValidation, StepRequestExtraction, +// StepTestCaseWithOptionalArgs, +// StepTransaction, StepRendezvous. +type IStep interface { + Name() string + Type() StepType + ToStruct() *TStep + Run(*SessionRunner) (*StepResult, error) } diff --git a/hrp/step_api.go b/hrp/step_api.go new file mode 100644 index 00000000..04397129 --- /dev/null +++ b/hrp/step_api.go @@ -0,0 +1,109 @@ +package hrp + +import ( + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp/internal/builtin" +) + +// IAPI represents interface for api, +// includes API and APIPath. +type IAPI interface { + GetPath() string + ToAPI() (*API, error) +} + +type API struct { + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` + TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` + Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` + Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` + Path string +} + +func (api *API) GetPath() string { + return api.Path +} + +func (api *API) ToAPI() (*API, error) { + return api, nil +} + +// APIPath implements IAPI interface. +type APIPath string + +func (path *APIPath) GetPath() string { + return fmt.Sprintf("%v", *path) +} + +func (path *APIPath) ToAPI() (*API, error) { + api := &API{} + apiPath := path.GetPath() + err := builtin.LoadFile(apiPath, api) + if err != nil { + return nil, err + } + err = convertCompatValidator(api.Validators) + return api, err +} + +// StepAPIWithOptionalArgs implements IStep interface. +type StepAPIWithOptionalArgs struct { + step *TStep +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepAPIWithOptionalArgs) TeardownHook(hook string) *StepAPIWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Export specifies variable names to export from referenced api for current step. +func (s *StepAPIWithOptionalArgs) Export(names ...string) *StepAPIWithOptionalArgs { + api, ok := s.step.API.(*API) + if ok { + s.step.Export = append(api.Export, names...) + } + return s +} + +func (s *StepAPIWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + api, ok := s.step.API.(*API) + if ok { + return api.Name + } + return "" +} + +func (s *StepAPIWithOptionalArgs) Type() StepType { + return stepTypeAPI +} + +func (s *StepAPIWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +func (s *StepAPIWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) { + log.Info().Str("api", s.step.Name).Msg("run referenced api") + + // extend request with referenced API + api, _ := s.step.API.(*API) + extendWithAPI(s.step, api) + + stepResult, err := runStepRequest(r, s.step) + if err != nil { + r.summary.Success = false + return nil, err + } + stepResult.StepType = stepTypeAPI + return stepResult, nil +} diff --git a/hrp/step_rendezvous.go b/hrp/step_rendezvous.go new file mode 100644 index 00000000..088a11b3 --- /dev/null +++ b/hrp/step_rendezvous.go @@ -0,0 +1,259 @@ +package hrp + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" +) + +// StepRendezvous implements IStep interface. +type StepRendezvous struct { + step *TStep +} + +func (s *StepRendezvous) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return s.step.Rendezvous.Name +} + +func (s *StepRendezvous) Type() StepType { + return stepTypeRendezvous +} + +func (s *StepRendezvous) ToStruct() *TStep { + return s.step +} + +func (s *StepRendezvous) Run(r *SessionRunner) (*StepResult, error) { + rendezvous := s.step.Rendezvous + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout", rendezvous.Timeout). + Msg("rendezvous") + + stepResult := &StepResult{ + Name: rendezvous.Name, + StepType: stepTypeRendezvous, + Success: true, + } + + // pass current rendezvous if already released, activate rendezvous sequentially after spawn done + if rendezvous.isReleased() || !isPreRendezvousAllReleased(rendezvous, r.testCase.ToTCase()) || !rendezvous.isSpawnDone() { + return stepResult, nil + } + + // activate the rendezvous only once during each cycle + rendezvous.once.Do(func() { + close(rendezvous.activateChan) + }) + + // check current cnt using double check lock before updating to avoid negative WaitGroup counter + if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { + rendezvous.lock.Lock() + if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { + atomic.AddInt64(&rendezvous.cnt, 1) + rendezvous.wg.Done() + rendezvous.timerResetChan <- struct{}{} + } + rendezvous.lock.Unlock() + } + + // block until current rendezvous released + <-rendezvous.releaseChan + return stepResult, nil +} + +func isPreRendezvousAllReleased(rendezvous *Rendezvous, testCase *TCase) bool { + for _, step := range testCase.TestSteps { + preRendezvous := step.Rendezvous + if preRendezvous == nil { + continue + } + // meet current rendezvous, all previous rendezvous released, return true + if preRendezvous == rendezvous { + return true + } + if !preRendezvous.isReleased() { + return false + } + } + return true +} + +// Rendezvous creates a new rendezvous +func (s *StepRequest) Rendezvous(name string) *StepRendezvous { + s.step.Rendezvous = &Rendezvous{ + Name: name, + } + return &StepRendezvous{ + step: s.step, + } +} + +// WithUserNumber sets the user number needed to release the current rendezvous +func (s *StepRendezvous) WithUserNumber(number int64) *StepRendezvous { + s.step.Rendezvous.Number = number + return s +} + +// WithUserPercent sets the user percent needed to release the current rendezvous +func (s *StepRendezvous) WithUserPercent(percent float32) *StepRendezvous { + s.step.Rendezvous.Percent = percent + return s +} + +// WithTimeout sets the timeout of duration between each user arriving at the current rendezvous +func (s *StepRendezvous) WithTimeout(timeout int64) *StepRendezvous { + s.step.Rendezvous.Timeout = timeout + return s +} + +const ( + defaultRendezvousTimeout int64 = 5000 + defaultRendezvousPercent float32 = 1.0 +) + +type Rendezvous struct { + Name string `json:"name" yaml:"name"` // required + Percent float32 `json:"percent,omitempty" yaml:"percent,omitempty"` // default to 1(100%) + Number int64 `json:"number,omitempty" yaml:"number,omitempty"` + Timeout int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // milliseconds + cnt int64 + releasedFlag uint32 + spawnDoneFlag uint32 + wg sync.WaitGroup + timerResetChan chan struct{} + activateChan chan struct{} + releaseChan chan struct{} + once *sync.Once + lock sync.Mutex +} + +func (r *Rendezvous) reset() { + r.cnt = 0 + r.releasedFlag = 0 + r.wg.Add(int(r.Number)) + // timerResetChan channel will not be closed, thus init only once + if r.timerResetChan == nil { + r.timerResetChan = make(chan struct{}) + } + r.activateChan = make(chan struct{}) + r.releaseChan = make(chan struct{}) + r.once = new(sync.Once) +} + +func (r *Rendezvous) isSpawnDone() bool { + return atomic.LoadUint32(&r.spawnDoneFlag) == 1 +} + +func (r *Rendezvous) setSpawnDone() { + atomic.StoreUint32(&r.spawnDoneFlag, 1) +} + +func (r *Rendezvous) isReleased() bool { + return atomic.LoadUint32(&r.releasedFlag) == 1 +} + +func (r *Rendezvous) setReleased() { + atomic.StoreUint32(&r.releasedFlag, 1) +} + +func initRendezvous(testcase *TestCase, total int64) []*Rendezvous { + tCase := testcase.ToTCase() + var rendezvousList []*Rendezvous + for _, step := range tCase.TestSteps { + if step.Rendezvous == nil { + continue + } + rendezvous := step.Rendezvous + + // either number or percent should be correctly put, otherwise set to default (total) + if rendezvous.Number == 0 && rendezvous.Percent > 0 && rendezvous.Percent <= defaultRendezvousPercent { + rendezvous.Number = int64(rendezvous.Percent * float32(total)) + } else if rendezvous.Number > 0 && rendezvous.Number <= total && rendezvous.Percent == 0 { + rendezvous.Percent = float32(rendezvous.Number) / float32(total) + } else { + log.Warn(). + Str("name", rendezvous.Name). + Int64("default number", total). + Float32("default percent", defaultRendezvousPercent). + Msg("rendezvous parameter not defined or error, set to default value") + rendezvous.Number = total + rendezvous.Percent = defaultRendezvousPercent + } + + if rendezvous.Timeout <= 0 { + rendezvous.Timeout = defaultRendezvousTimeout + } + + rendezvous.reset() + rendezvousList = append(rendezvousList, rendezvous) + } + return rendezvousList +} + +func waitRendezvous(rendezvousList []*Rendezvous) { + if rendezvousList != nil { + lastRendezvous := rendezvousList[len(rendezvousList)-1] + for _, rendezvous := range rendezvousList { + go waitSingleRendezvous(rendezvous, rendezvousList, lastRendezvous) + } + } +} + +func waitSingleRendezvous(rendezvous *Rendezvous, rendezvousList []*Rendezvous, lastRendezvous *Rendezvous) { + for { + // cycle start: block current checking until current rendezvous activated + <-rendezvous.activateChan + stop := make(chan struct{}) + timeout := time.Duration(rendezvous.Timeout) * time.Millisecond + timer := time.NewTimer(timeout) + go func() { + defer close(stop) + rendezvous.wg.Wait() + }() + for !rendezvous.isReleased() { + select { + case <-rendezvous.timerResetChan: + timer.Reset(timeout) + case <-stop: + rendezvous.setReleased() + close(rendezvous.releaseChan) + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout(ms)", rendezvous.Timeout). + Int64("cnt", rendezvous.cnt). + Str("reason", "rendezvous release condition satisfied"). + Msg("rendezvous released") + case <-timer.C: + rendezvous.setReleased() + close(rendezvous.releaseChan) + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout(ms)", rendezvous.Timeout). + Int64("cnt", rendezvous.cnt). + Str("reason", "time's up"). + Msg("rendezvous released") + } + } + // cycle end: reset all previous rendezvous after last rendezvous released + // otherwise, block current checker until the last rendezvous end + if rendezvous == lastRendezvous { + for _, r := range rendezvousList { + r.reset() + } + } else { + <-lastRendezvous.releaseChan + } + } +} diff --git a/hrp/step_rendezvous_test.go b/hrp/step_rendezvous_test.go new file mode 100644 index 00000000..5f9ed709 --- /dev/null +++ b/hrp/step_rendezvous_test.go @@ -0,0 +1,71 @@ +package hrp + +import ( + "math" + "testing" +) + +func TestRunCaseWithRendezvous(t *testing.T) { + rendezvousBoundaryTestcase := &TestCase{ + Config: NewConfig("run request with functions"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + }), + TestSteps: []IStep{ + NewStep("test negative number"). + Rendezvous("test negative number"). + WithUserNumber(-1), + NewStep("test overflow number"). + Rendezvous("test overflow number"). + WithUserNumber(1000000), + NewStep("test negative percent"). + Rendezvous("test very low percent"). + WithUserPercent(-0.5), + NewStep("test very low percent"). + Rendezvous("test very low percent"). + WithUserPercent(0.00001), + NewStep("test overflow percent"). + Rendezvous("test overflow percent"). + WithUserPercent(1.5), + NewStep("test conflict params"). + Rendezvous("test conflict params"). + WithUserNumber(1). + WithUserPercent(0.123), + NewStep("test negative timeout"). + Rendezvous("test negative timeout"). + WithTimeout(-1000), + }, + } + + type rendezvousParam struct { + number int64 + percent float32 + timeout int64 + } + expectedRendezvousParams := []rendezvousParam{ + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 0, percent: 0.00001, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + } + + rendezvousList := initRendezvous(rendezvousBoundaryTestcase, 100) + + for i, r := range rendezvousList { + if r.Number != expectedRendezvousParams[i].number { + t.Fatalf("run rendezvous %v error: expected number: %v, real number: %v", r.Name, expectedRendezvousParams[i].number, r.Number) + } + if math.Abs(float64(r.Percent-expectedRendezvousParams[i].percent)) > 0.001 { + t.Fatalf("run rendezvous %v error: expected percent: %v, real percent: %v", r.Name, expectedRendezvousParams[i].percent, r.Percent) + } + if r.Timeout != expectedRendezvousParams[i].timeout { + t.Fatalf("run rendezvous %v error: expected timeout: %v, real timeout: %v", r.Name, expectedRendezvousParams[i].timeout, r.Timeout) + } + } +} diff --git a/hrp/step_request.go b/hrp/step_request.go new file mode 100644 index 00000000..f3560365 --- /dev/null +++ b/hrp/step_request.go @@ -0,0 +1,924 @@ +package hrp + +import ( + "bytes" + "compress/gzip" + "compress/zlib" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/andybalholm/brotli" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/json" +) + +type HTTPMethod string + +const ( + httpGET HTTPMethod = "GET" + httpHEAD HTTPMethod = "HEAD" + httpPOST HTTPMethod = "POST" + httpPUT HTTPMethod = "PUT" + httpDELETE HTTPMethod = "DELETE" + httpOPTIONS HTTPMethod = "OPTIONS" + httpPATCH HTTPMethod = "PATCH" +) + +// Request represents HTTP request data structure. +// This is used for teststep. +type Request struct { + Method HTTPMethod `json:"method" yaml:"method"` // required + URL string `json:"url" yaml:"url"` // required + Params map[string]interface{} `json:"params,omitempty" yaml:"params,omitempty"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` + Cookies map[string]string `json:"cookies,omitempty" yaml:"cookies,omitempty"` + Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` + Json interface{} `json:"json,omitempty" yaml:"json,omitempty"` + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` + Timeout float32 `json:"timeout,omitempty" yaml:"timeout,omitempty"` + AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"` + Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` +} + +func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err error) { + + step, err = r.overrideVariables(step) + if err != nil { + return nil, err + } + r.overrideConfig(step) + + log.Info().Str("step", step.Name).Msg("run step start") + stepResult = &StepResult{ + Name: step.Name, + StepType: stepTypeRequest, + Success: false, + ContentSize: 0, + } + sessionData := newSessionData() + + defer func() { + if err != nil { + log.Error().Err(err).Msg("run request step failed") + stepResult.Attachment = err.Error() + r.summary.Success = false + } else { + log.Info(). + Str("step", step.Name). + Bool("success", stepResult.Success). + Interface("exportVars", stepResult.ExportVars). + Msg("run step end") + } + }() + + // convert request struct to map + jsonRequest, _ := json.Marshal(&step.Request) + var requestMap map[string]interface{} + _ = json.Unmarshal(jsonRequest, &requestMap) + + rawUrl := step.Request.URL + method := step.Request.Method + req := &http.Request{ + Method: string(method), + Header: make(http.Header), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + } + + // prepare request headers + if len(step.Request.Headers) > 0 { + headers, err := r.parser.parseHeaders(step.Request.Headers, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse headers failed") + } + for key, value := range headers { + // omit pseudo header names for HTTP/1, e.g. :authority, :method, :path, :scheme + if strings.HasPrefix(key, ":") { + continue + } + req.Header.Add(key, value) + + // prepare content length + if strings.EqualFold(key, "Content-Length") && value != "" { + if l, err := strconv.ParseInt(value, 10, 64); err == nil { + req.ContentLength = l + } + } + } + } + + // prepare request params + var queryParams url.Values + if len(step.Request.Params) > 0 { + params, err := r.parser.parseData(step.Request.Params, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse request params failed") + } + parsedParams := params.(map[string]interface{}) + requestMap["params"] = parsedParams + if len(parsedParams) > 0 { + queryParams = make(url.Values) + for k, v := range parsedParams { + queryParams.Add(k, fmt.Sprint(v)) + } + } + } + if queryParams != nil { + // append params to url + paramStr := queryParams.Encode() + if strings.IndexByte(rawUrl, '?') == -1 { + rawUrl = rawUrl + "?" + paramStr + } else { + rawUrl = rawUrl + "&" + paramStr + } + } + + // prepare request cookies + for cookieName, cookieValue := range step.Request.Cookies { + value, err := r.parser.parseData(cookieValue, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse cookie value failed") + } + req.AddCookie(&http.Cookie{ + Name: cookieName, + Value: fmt.Sprintf("%v", value), + }) + } + + // prepare request body + if step.Request.Body != nil { + data, err := r.parser.parseData(step.Request.Body, step.Variables) + if err != nil { + return stepResult, err + } + // check request body format if Content-Type specified as application/json + if strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") { + switch data.(type) { + case bool, float64, string, map[string]interface{}, []interface{}, nil: + break + default: + return stepResult, errors.Errorf("request body type inconsistent with Content-Type: %v", req.Header.Get("Content-Type")) + } + } + requestMap["body"] = data + var dataBytes []byte + switch vv := data.(type) { + case map[string]interface{}: + contentType := req.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + // post form data + formData := make(url.Values) + for k, v := range vv { + formData.Add(k, fmt.Sprint(v)) + } + dataBytes = []byte(formData.Encode()) + } else { + // post json + dataBytes, err = json.Marshal(vv) + if err != nil { + return stepResult, err + } + if contentType == "" { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + } + case []interface{}: + contentType := req.Header.Get("Content-Type") + // post json + dataBytes, err = json.Marshal(vv) + if err != nil { + return stepResult, err + } + if contentType == "" { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + case string: + dataBytes = []byte(vv) + case []byte: + dataBytes = vv + case bytes.Buffer: + dataBytes = vv.Bytes() + default: // unexpected body type + return stepResult, errors.New("unexpected request body type") + } + + req.Body = io.NopCloser(bytes.NewReader(dataBytes)) + req.ContentLength = int64(len(dataBytes)) + } + // update header + headers := make(map[string]string) + for key, value := range req.Header { + headers[key] = value[0] + } + requestMap["headers"] = headers + + // prepare url + u, err := url.Parse(rawUrl) + if err != nil { + return stepResult, errors.Wrap(err, "parse url failed") + } + req.URL = u + req.Host = u.Host + + // add request object to step variables, could be used in setup hooks + step.Variables["hrp_step_name"] = step.Name + step.Variables["hrp_step_request"] = requestMap + + // deal with setup hooks + for _, setupHook := range step.SetupHooks { + _, err = r.parser.parseData(setupHook, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "run setup hooks failed") + } + } + + // log & print request + if r.hrpRunner.requestsLogOn { + if err := printRequest(req); err != nil { + return stepResult, err + } + } + + // do request action + start := time.Now() + resp, err := r.hrpRunner.client.Do(req) + stepResult.Elapsed = time.Since(start).Milliseconds() + if err != nil { + return stepResult, errors.Wrap(err, "do request failed") + } + defer resp.Body.Close() + + // decode response body in br/gzip/deflate formats + err = decodeResponseBody(resp) + if err != nil { + return stepResult, errors.Wrap(err, "decode response body failed") + } + + // log & print response + if r.hrpRunner.requestsLogOn { + if err := printResponse(resp); err != nil { + return stepResult, err + } + } + + // new response object + respObj, err := newResponseObject(r.hrpRunner.t, r.parser, resp) + if err != nil { + err = errors.Wrap(err, "init ResponseObject error") + return + } + + // add response object to step variables, could be used in teardown hooks + step.Variables["hrp_step_response"] = respObj.respObjMeta + + // deal with teardown hooks + for _, teardownHook := range step.TeardownHooks { + _, err = r.parser.parseData(teardownHook, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "run teardown hooks failed") + } + } + + sessionData.ReqResps.Request = requestMap + sessionData.ReqResps.Response = builtin.FormatResponse(respObj.respObjMeta) + + // extract variables from response + extractors := step.Extract + extractMapping := respObj.Extract(extractors) + stepResult.ExportVars = extractMapping + + // update extracted variables + for k, v := range stepResult.ExportVars { + r.sessionVariables[k] = v + } + + // override step variables with extracted variables + stepVariables := mergeVariables(step.Variables, extractMapping) + + // validate response + err = respObj.Validate(step.Validators, stepVariables) + sessionData.Validators = respObj.validationResults + if err == nil { + sessionData.Success = true + stepResult.Success = true + } + stepResult.ContentSize = resp.ContentLength + stepResult.Data = sessionData + + // append step result to summary + r.summary.Records = append(r.summary.Records, stepResult) + r.summary.Stat.Total += 1 + if stepResult.Success { + r.summary.Stat.Successes += 1 + } else { + r.summary.Stat.Failures += 1 + } + + return stepResult, err +} + +func printRequest(req *http.Request) error { + reqContentType := req.Header.Get("Content-Type") + printBody := shouldPrintBody(reqContentType) + reqDump, err := httputil.DumpRequest(req, printBody) + if err != nil { + return errors.Wrap(err, "dump request failed") + } + fmt.Println("-------------------- request --------------------") + reqContent := string(reqDump) + if req.Body != nil && !printBody { + reqContent += fmt.Sprintf("(request body omitted for Content-Type: %v)", reqContentType) + } + fmt.Println(reqContent) + return nil +} + +func printResponse(resp *http.Response) error { + fmt.Println("==================== response ===================") + respContentType := resp.Header.Get("Content-Type") + printBody := shouldPrintBody(respContentType) + respDump, err := httputil.DumpResponse(resp, printBody) + if err != nil { + return errors.Wrap(err, "dump response failed") + } + respContent := string(respDump) + if !printBody { + respContent += fmt.Sprintf("(response body omitted for Content-Type: %v)", respContentType) + } + fmt.Println(respContent) + fmt.Println("--------------------------------------------------") + return nil +} + +func decodeResponseBody(resp *http.Response) (err error) { + switch resp.Header.Get("Content-Encoding") { + case "br": + resp.Body = io.NopCloser(brotli.NewReader(resp.Body)) + case "gzip": + resp.Body, err = gzip.NewReader(resp.Body) + if err != nil { + return err + } + resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched + case "deflate": + resp.Body, err = zlib.NewReader(resp.Body) + if err != nil { + return err + } + resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched + } + return nil +} + +// shouldPrintBody return true if the Content-Type is printable +// including text/*, application/json, application/xml, application/www-form-urlencoded +func shouldPrintBody(contentType string) bool { + if strings.HasPrefix(contentType, "text/") { + return true + } + if strings.HasPrefix(contentType, "application/json") { + return true + } + if strings.HasPrefix(contentType, "application/xml") { + return true + } + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + return true + } + return false +} + +// NewStep returns a new constructed teststep with specified step name. +func NewStep(name string) *StepRequest { + return &StepRequest{ + step: &TStep{ + Name: name, + Variables: make(map[string]interface{}), + }, + } +} + +type StepRequest struct { + step *TStep +} + +// WithVariables sets variables for current teststep. +func (s *StepRequest) WithVariables(variables map[string]interface{}) *StepRequest { + s.step.Variables = variables + return s +} + +// SetupHook adds a setup hook for current teststep. +func (s *StepRequest) SetupHook(hook string) *StepRequest { + s.step.SetupHooks = append(s.step.SetupHooks, hook) + return s +} + +// GET makes a HTTP GET request. +func (s *StepRequest) GET(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpGET, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// HEAD makes a HTTP HEAD request. +func (s *StepRequest) HEAD(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpHEAD, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// POST makes a HTTP POST request. +func (s *StepRequest) POST(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPOST, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// PUT makes a HTTP PUT request. +func (s *StepRequest) PUT(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPUT, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// DELETE makes a HTTP DELETE request. +func (s *StepRequest) DELETE(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpDELETE, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// OPTIONS makes a HTTP OPTIONS request. +func (s *StepRequest) OPTIONS(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpOPTIONS, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// PATCH makes a HTTP PATCH request. +func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPATCH, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// CallRefCase calls a referenced testcase. +func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs { + var err error + s.step.TestCase, err = tc.ToTestCase() + if err != nil { + log.Error().Err(err).Msg("failed to load testcase") + os.Exit(1) + } + return &StepTestCaseWithOptionalArgs{ + step: s.step, + } +} + +// CallRefAPI calls a referenced api. +func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs { + var err error + s.step.API, err = api.ToAPI() + if err != nil { + log.Error().Err(err).Msg("failed to load api") + os.Exit(1) + } + return &StepAPIWithOptionalArgs{ + step: s.step, + } +} + +// StartTransaction starts a transaction. +func (s *StepRequest) StartTransaction(name string) *StepTransaction { + s.step.Transaction = &Transaction{ + Name: name, + Type: transactionStart, + } + return &StepTransaction{ + step: s.step, + } +} + +// EndTransaction ends a transaction. +func (s *StepRequest) EndTransaction(name string) *StepTransaction { + s.step.Transaction = &Transaction{ + Name: name, + Type: transactionEnd, + } + return &StepTransaction{ + step: s.step, + } +} + +// SetThinkTime sets think time. +func (s *StepRequest) SetThinkTime(time float64) *StepThinkTime { + s.step.ThinkTime = &ThinkTime{ + Time: time, + } + return &StepThinkTime{ + step: s.step, + } +} + +// StepRequestWithOptionalArgs implements IStep interface. +type StepRequestWithOptionalArgs struct { + step *TStep +} + +// SetVerify sets whether to verify SSL for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetVerify(verify bool) *StepRequestWithOptionalArgs { + s.step.Request.Verify = verify + return s +} + +// SetTimeout sets timeout for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetTimeout(timeout float32) *StepRequestWithOptionalArgs { + s.step.Request.Timeout = timeout + return s +} + +// SetProxies sets proxies for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetProxies(proxies map[string]string) *StepRequestWithOptionalArgs { + // TODO + return s +} + +// SetAllowRedirects sets whether to allow redirects for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetAllowRedirects(allowRedirects bool) *StepRequestWithOptionalArgs { + s.step.Request.AllowRedirects = allowRedirects + return s +} + +// SetAuth sets auth for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetAuth(auth map[string]string) *StepRequestWithOptionalArgs { + // TODO + return s +} + +// WithParams sets HTTP request params for current step. +func (s *StepRequestWithOptionalArgs) WithParams(params map[string]interface{}) *StepRequestWithOptionalArgs { + s.step.Request.Params = params + return s +} + +// WithHeaders sets HTTP request headers for current step. +func (s *StepRequestWithOptionalArgs) WithHeaders(headers map[string]string) *StepRequestWithOptionalArgs { + s.step.Request.Headers = headers + return s +} + +// WithCookies sets HTTP request cookies for current step. +func (s *StepRequestWithOptionalArgs) WithCookies(cookies map[string]string) *StepRequestWithOptionalArgs { + s.step.Request.Cookies = cookies + return s +} + +// WithBody sets HTTP request body for current step. +func (s *StepRequestWithOptionalArgs) WithBody(body interface{}) *StepRequestWithOptionalArgs { + s.step.Request.Body = body + return s +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepRequestWithOptionalArgs) TeardownHook(hook string) *StepRequestWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Validate switches to step validation. +func (s *StepRequestWithOptionalArgs) Validate() *StepRequestValidation { + return &StepRequestValidation{ + step: s.step, + } +} + +// Extract switches to step extraction. +func (s *StepRequestWithOptionalArgs) Extract() *StepRequestExtraction { + s.step.Extract = make(map[string]string) + return &StepRequestExtraction{ + step: s.step, + } +} + +func (s *StepRequestWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("%v %s", s.step.Request.Method, s.step.Request.URL) +} + +func (s *StepRequestWithOptionalArgs) Type() StepType { + return StepType(fmt.Sprintf("request-%v", s.step.Request.Method)) +} + +func (s *StepRequestWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +func (s *StepRequestWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) { + return runStepRequest(r, s.step) +} + +// StepRequestExtraction implements IStep interface. +type StepRequestExtraction struct { + step *TStep +} + +// WithJmesPath sets the JMESPath expression to extract from the response. +func (s *StepRequestExtraction) WithJmesPath(jmesPath string, varName string) *StepRequestExtraction { + s.step.Extract[varName] = jmesPath + return s +} + +// Validate switches to step validation. +func (s *StepRequestExtraction) Validate() *StepRequestValidation { + return &StepRequestValidation{ + step: s.step, + } +} + +func (s *StepRequestExtraction) Name() string { + return s.step.Name +} + +func (s *StepRequestExtraction) Type() StepType { + return StepType(fmt.Sprintf("request-%v", s.step.Request.Method)) +} + +func (s *StepRequestExtraction) ToStruct() *TStep { + return s.step +} + +func (s *StepRequestExtraction) Run(r *SessionRunner) (*StepResult, error) { + return runStepRequest(r, s.step) +} + +// StepRequestValidation implements IStep interface. +type StepRequestValidation struct { + step *TStep +} + +func (s *StepRequestValidation) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL) +} + +func (s *StepRequestValidation) Type() StepType { + return StepType(fmt.Sprintf("request-%v", s.step.Request.Method)) +} + +func (s *StepRequestValidation) ToStruct() *TStep { + return s.step +} + +func (s *StepRequestValidation) Run(r *SessionRunner) (*StepResult, error) { + return runStepRequest(r, s.step) +} + +func (s *StepRequestValidation) AssertEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertGreater(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "greater_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLess(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "less_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertGreaterOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "greater_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLessOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "less_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertNotEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "not_equal", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertContains(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "contains", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertTypeMatch(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "type_match", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertRegexp(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "regex_match", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertStartsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "startswith", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertEndsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "endswith", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertContainedBy(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "contained_by", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthLessThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_less_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "string_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_less_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthGreaterThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_greater_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthGreaterOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_greater_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +// Validator represents validator for one HTTP response. +type Validator struct { + Check string `json:"check" yaml:"check"` // get value with jmespath + Assert string `json:"assert" yaml:"assert"` + Expect interface{} `json:"expect" yaml:"expect"` + Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional +} diff --git a/hrp/step_test.go b/hrp/step_request_test.go similarity index 78% rename from hrp/step_test.go rename to hrp/step_request_test.go index c84a07cb..50becbec 100644 --- a/hrp/step_test.go +++ b/hrp/step_request_test.go @@ -16,13 +16,13 @@ var ( AssertEqual("body.args.foo1", "bar1", "check param foo1"). AssertEqual("body.args.foo2", "bar2", "check param foo2") stepPOSTData = NewStep("post form data"). - POST("/post"). - WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). - WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus", "Content-Type": "application/x-www-form-urlencoded"}). - WithBody("a=1&b=2"). - WithCookies(map[string]string{"user": "debugtalk"}). - Validate(). - AssertEqual("status_code", 200, "check status code") + POST("/post"). + WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus", "Content-Type": "application/x-www-form-urlencoded"}). + WithBody("a=1&b=2"). + WithCookies(map[string]string{"user": "debugtalk"}). + Validate(). + AssertEqual("status_code", 200, "check status code") ) func TestRunRequestGetToStruct(t *testing.T) { @@ -79,11 +79,12 @@ func TestRunRequestRun(t *testing.T) { Config: NewConfig("test").SetBaseURL("https://postman-echo.com"), TestSteps: []IStep{stepGET, stepPOSTData}, } - runner := NewRunner(t).SetRequestsLogOn().newCaseRunner(testcase) - if _, err := runner.runStep(0, testcase.Config); err != nil { - t.Fatalf("tStep.Run() error: %s", err) + runner := NewRunner(t).SetRequestsLogOn() + sessionRunner := runner.NewSessionRunner(testcase) + if _, err := stepGET.Run(sessionRunner); err != nil { + t.Fatalf("stepGET.Run() error: %v", err) } - if _, err := runner.runStep(1, testcase.Config); err != nil { - t.Fatalf("tStepPOSTData.Run() error: %s", err) + if _, err := stepPOSTData.Run(sessionRunner); err != nil { + t.Fatalf("stepPOSTData.Run() error: %v", err) } } diff --git a/hrp/step_testcase.go b/hrp/step_testcase.go new file mode 100644 index 00000000..1ef36b37 --- /dev/null +++ b/hrp/step_testcase.go @@ -0,0 +1,105 @@ +package hrp + +import ( + "time" + + "github.com/jinzhu/copier" + "github.com/rs/zerolog/log" +) + +// StepTestCaseWithOptionalArgs implements IStep interface. +type StepTestCaseWithOptionalArgs struct { + step *TStep +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepTestCaseWithOptionalArgs) TeardownHook(hook string) *StepTestCaseWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Export specifies variable names to export from referenced testcase for current step. +func (s *StepTestCaseWithOptionalArgs) Export(names ...string) *StepTestCaseWithOptionalArgs { + s.step.Export = append(s.step.Export, names...) + return s +} + +func (s *StepTestCaseWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + ts, ok := s.step.TestCase.(*TestCase) + if ok { + return ts.Config.Name + } + return "" +} + +func (s *StepTestCaseWithOptionalArgs) Type() StepType { + return stepTypeTestCase +} + +func (s *StepTestCaseWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) { + copiedStep, err := r.overrideVariables(s.step) + if err != nil { + return nil, err + } + + log.Info().Str("testcase", copiedStep.Name).Msg("run referenced testcase") + stepResult := &StepResult{ + Name: copiedStep.Name, + StepType: stepTypeTestCase, + Success: false, + } + testcase := copiedStep.TestCase.(*TestCase) + + // copy testcase to avoid data racing + copiedTestCase := &TestCase{} + if err := copier.Copy(copiedTestCase, testcase); err != nil { + log.Error().Err(err).Msg("copy testcase failed") + return stepResult, err + } + // override testcase config + extendWithTestCase(copiedStep, copiedTestCase) + + sessionRunner := r.hrpRunner.NewSessionRunner(copiedTestCase) + + start := time.Now() + err = sessionRunner.Run() + stepResult.Elapsed = time.Since(start).Milliseconds() + if err != nil { + log.Error().Err(err).Msg("run referenced testcase step failed") + log.Info().Str("step", copiedStep.Name).Bool("success", false).Msg("run step end") + stepResult.Attachment = err.Error() + r.summary.Success = false + return stepResult, err + } + summary := sessionRunner.getSummary() + stepResult.Data = summary + // export testcase export variables + stepResult.ExportVars = sessionRunner.summary.InOut.ExportVars + stepResult.Success = true + + // update extracted variables + for k, v := range stepResult.ExportVars { + r.sessionVariables[k] = v + } + + // merge testcase summary + r.summary.Records = append(r.summary.Records, summary.Records...) + r.summary.Stat.Total += summary.Stat.Total + r.summary.Stat.Successes += summary.Stat.Successes + r.summary.Stat.Failures += summary.Stat.Failures + + log.Info(). + Str("step", copiedStep.Name). + Bool("success", true). + Interface("exportVars", stepResult.ExportVars). + Msg("run step end") + + return stepResult, nil +} diff --git a/hrp/step_thinktime.go b/hrp/step_thinktime.go new file mode 100644 index 00000000..8e90398d --- /dev/null +++ b/hrp/step_thinktime.go @@ -0,0 +1,80 @@ +package hrp + +import ( + "time" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/rs/zerolog/log" +) + +type ThinkTime struct { + Time float64 `json:"time" yaml:"time"` +} + +// StepThinkTime implements IStep interface. +type StepThinkTime struct { + step *TStep +} + +func (s *StepThinkTime) Name() string { + return s.step.Name +} + +func (s *StepThinkTime) Type() StepType { + return stepTypeThinkTime +} + +func (s *StepThinkTime) ToStruct() *TStep { + return s.step +} + +func (s *StepThinkTime) Run(r *SessionRunner) (*StepResult, error) { + thinkTime := s.step.ThinkTime + log.Info().Str("name", s.step.Name). + Float64("time", thinkTime.Time).Msg("think time") + + stepResult := &StepResult{ + Name: s.step.Name, + StepType: stepTypeThinkTime, + Success: true, + } + + cfg := r.testCase.Config.ThinkTimeSetting + if cfg == nil { + cfg = &ThinkTimeConfig{thinkTimeDefault, nil, 0} + } + + var tt time.Duration + switch cfg.Strategy { + case thinkTimeDefault: + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + case thinkTimeRandomPercentage: + // e.g. {"min_percentage": 0.5, "max_percentage": 1.5} + m, ok := cfg.Setting.(map[string]float64) + if !ok { + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + break + } + res := builtin.GetRandomNumber(int(thinkTime.Time*m["min_percentage"]*1000), int(thinkTime.Time*m["max_percentage"]*1000)) + tt = time.Duration(res) * time.Millisecond + case thinkTimeMultiply: + value, ok := cfg.Setting.(float64) // e.g. 0.5 + if !ok || value <= 0 { + value = thinkTimeDefaultMultiply + } + tt = time.Duration(thinkTime.Time*value*1000) * time.Millisecond + case thinkTimeIgnore: + // nothing to do + } + + // no more than limit + if cfg.Limit > 0 { + limit := time.Duration(cfg.Limit*1000) * time.Millisecond + if limit < tt { + tt = limit + } + } + + time.Sleep(tt) + return stepResult, nil +} diff --git a/hrp/step_transaction.go b/hrp/step_transaction.go new file mode 100644 index 00000000..20ebb67e --- /dev/null +++ b/hrp/step_transaction.go @@ -0,0 +1,83 @@ +package hrp + +import ( + "fmt" + "time" + + "github.com/rs/zerolog/log" +) + +type Transaction struct { + Name string `json:"name" yaml:"name"` + Type transactionType `json:"type" yaml:"type"` +} + +type transactionType string + +const ( + transactionStart transactionType = "start" + transactionEnd transactionType = "end" +) + +// StepTransaction implements IStep interface. +type StepTransaction struct { + step *TStep +} + +func (s *StepTransaction) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("transaction %s %s", s.step.Transaction.Name, s.step.Transaction.Type) +} + +func (s *StepTransaction) Type() StepType { + return stepTypeTransaction +} + +func (s *StepTransaction) ToStruct() *TStep { + return s.step +} + +func (s *StepTransaction) Run(r *SessionRunner) (*StepResult, error) { + transaction := s.step.Transaction + log.Info(). + Str("name", transaction.Name). + Str("type", string(transaction.Type)). + Msg("transaction") + + stepResult := &StepResult{ + Name: transaction.Name, + StepType: stepTypeTransaction, + Success: true, + Elapsed: 0, + ContentSize: 0, // TODO: record transaction total response length + } + + // create transaction if not exists + if _, ok := r.transactions[transaction.Name]; !ok { + r.transactions[transaction.Name] = make(map[transactionType]time.Time) + } + + // record transaction start time, override if already exists + if transaction.Type == transactionStart { + r.transactions[transaction.Name][transactionStart] = time.Now() + } + // record transaction end time, override if already exists + if transaction.Type == transactionEnd { + r.transactions[transaction.Name][transactionEnd] = time.Now() + + // if transaction start time not exists, use testcase start time instead + if _, ok := r.transactions[transaction.Name][transactionStart]; !ok { + r.transactions[transaction.Name][transactionStart] = r.startTime + } + + // calculate transaction duration + duration := r.transactions[transaction.Name][transactionEnd].Sub( + r.transactions[transaction.Name][transactionStart]) + stepResult.Elapsed = duration.Milliseconds() + log.Info().Str("name", transaction.Name).Dur("elapsed", duration).Msg("transaction") + } + + return stepResult, nil +} diff --git a/hrp/summary.go b/hrp/summary.go new file mode 100644 index 00000000..6d6d8c27 --- /dev/null +++ b/hrp/summary.go @@ -0,0 +1,173 @@ +package hrp + +import ( + "bufio" + _ "embed" + "fmt" + "html/template" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/version" + "github.com/rs/zerolog/log" +) + +func newOutSummary() *Summary { + platForm := &Platform{ + HttprunnerVersion: version.VERSION, + GoVersion: runtime.Version(), + Platform: fmt.Sprintf("%v-%v", runtime.GOOS, runtime.GOARCH), + } + return &Summary{ + Success: true, + Stat: &Stat{}, + Time: &TestCaseTime{ + StartAt: time.Now(), + }, + Platform: platForm, + } +} + +// Summary stores tests summary for current task execution, maybe include one or multiple testcases +type Summary struct { + Success bool `json:"success" yaml:"success"` + Stat *Stat `json:"stat" yaml:"stat"` + Time *TestCaseTime `json:"time" yaml:"time"` + Platform *Platform `json:"platform" yaml:"platform"` + Details []*TestCaseSummary `json:"details" yaml:"details"` +} + +func (s *Summary) appendCaseSummary(caseSummary *TestCaseSummary) { + s.Success = s.Success && caseSummary.Success + s.Stat.TestCases.Total += 1 + s.Stat.TestSteps.Total += len(caseSummary.Records) + if caseSummary.Success { + s.Stat.TestCases.Success += 1 + } else { + s.Stat.TestCases.Fail += 1 + } + s.Stat.TestSteps.Successes += caseSummary.Stat.Successes + s.Stat.TestSteps.Failures += caseSummary.Stat.Failures + s.Details = append(s.Details, caseSummary) + s.Success = s.Success && caseSummary.Success +} + +func (s *Summary) genHTMLReport() error { + dir, _ := filepath.Split(reportPath) + err := builtin.EnsureFolderExists(dir) + if err != nil { + return err + } + file, err := os.OpenFile(fmt.Sprintf(reportPath, s.Time.StartAt.Unix()), os.O_WRONLY|os.O_CREATE, 0666) + if err != nil { + log.Error().Err(err).Msg("open file failed") + return err + } + defer file.Close() + writer := bufio.NewWriter(file) + tmpl := template.Must(template.New("report").Parse(reportTemplate)) + err = tmpl.Execute(writer, s) + if err != nil { + log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") + return err + } + err = writer.Flush() + return err +} + +//go:embed internal/scaffold/templates/report/template.html +var reportTemplate string + +const ( + reportPath string = "reports/report-%v.html" + summaryPath string = "reports/summary-%v.json" +) + +type Stat struct { + TestCases TestCaseStat `json:"testcases" yaml:"test_cases"` + TestSteps TestStepStat `json:"teststeps" yaml:"test_steps"` +} + +type TestCaseStat struct { + Total int `json:"total" yaml:"total"` + Success int `json:"success" yaml:"success"` + Fail int `json:"fail" yaml:"fail"` +} + +type TestStepStat struct { + Total int `json:"total" yaml:"total"` + Successes int `json:"successes" yaml:"successes"` + Failures int `json:"failures" yaml:"failures"` +} + +type TestCaseTime struct { + StartAt time.Time `json:"start_at,omitempty" yaml:"start_at,omitempty"` + Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` +} + +type Platform struct { + HttprunnerVersion string `json:"httprunner_version" yaml:"httprunner_version"` + GoVersion string `json:"go_version" yaml:"go_version"` + Platform string `json:"platform" yaml:"platform"` +} + +// TestCaseSummary stores tests summary for one testcase +type TestCaseSummary struct { + Name string `json:"name" yaml:"name"` + Success bool `json:"success" yaml:"success"` + CaseId string `json:"case_id,omitempty" yaml:"case_id,omitempty"` // TODO + Stat *TestStepStat `json:"stat" yaml:"stat"` + Time *TestCaseTime `json:"time" yaml:"time"` + InOut *TestCaseInOut `json:"in_out" yaml:"in_out"` + Log string `json:"log,omitempty" yaml:"log,omitempty"` // TODO + Records []*StepResult `json:"records" yaml:"records"` +} + +type TestCaseInOut struct { + ConfigVars map[string]interface{} `json:"config_vars" yaml:"config_vars"` + ExportVars map[string]interface{} `json:"export_vars" yaml:"export_vars"` +} + +func newSessionData() *SessionData { + return &SessionData{ + Success: false, + ReqResps: &ReqResps{}, + } +} + +type SessionData struct { + Success bool `json:"success" yaml:"success"` + ReqResps *ReqResps `json:"req_resps" yaml:"req_resps"` + Address *Address `json:"address,omitempty" yaml:"address,omitempty"` // TODO + Validators []*ValidationResult `json:"validators,omitempty" yaml:"validators,omitempty"` +} + +type ReqResps struct { + Request interface{} `json:"request" yaml:"request"` + Response interface{} `json:"response" yaml:"response"` +} + +type Address struct { + ClientIP string `json:"client_ip,omitempty" yaml:"client_ip,omitempty"` + ClientPort string `json:"client_port,omitempty" yaml:"client_port,omitempty"` + ServerIP string `json:"server_ip,omitempty" yaml:"server_ip,omitempty"` + ServerPort string `json:"server_port,omitempty" yaml:"server_port,omitempty"` +} + +type ValidationResult struct { + Validator + CheckValue interface{} `json:"check_value" yaml:"check_value"` + CheckResult string `json:"check_result" yaml:"check_result"` +} + +func newSummary() *TestCaseSummary { + return &TestCaseSummary{ + Success: true, + Stat: &TestStepStat{}, + Time: &TestCaseTime{}, + InOut: &TestCaseInOut{}, + } +} diff --git a/hrp/summary_test.go b/hrp/summary_test.go new file mode 100644 index 00000000..32343e0e --- /dev/null +++ b/hrp/summary_test.go @@ -0,0 +1,24 @@ +package hrp + +import "testing" + +func TestGenHTMLReport(t *testing.T) { + summary := newOutSummary() + caseSummary1 := newSummary() + caseSummary2 := newSummary() + stepResult1 := &StepResult{} + stepResult2 := &StepResult{ + Name: "Test", + StepType: stepTypeRequest, + Success: false, + ContentSize: 0, + Attachment: "err", + } + caseSummary1.Records = []*StepResult{stepResult1, stepResult2, nil} + summary.appendCaseSummary(caseSummary1) + summary.appendCaseSummary(caseSummary2) + err := summary.genHTMLReport() + if err != nil { + t.Error(err) + } +} diff --git a/hrp/convert.go b/hrp/testcase.go similarity index 66% rename from hrp/convert.go rename to hrp/testcase.go index 035bfadc..733d8241 100644 --- a/hrp/convert.go +++ b/hrp/testcase.go @@ -2,123 +2,46 @@ package hrp import ( "fmt" + "io/fs" "os" "path/filepath" "strings" + "github.com/httprunner/httprunner/hrp/internal/builtin" "github.com/pkg/errors" "github.com/rs/zerolog/log" - - "github.com/httprunner/httprunner/hrp/internal/builtin" ) -func convertCompatValidator(Validators []interface{}) (err error) { - for i, iValidator := range Validators { - validatorMap := iValidator.(map[string]interface{}) - validator := Validator{} - _, checkExisted := validatorMap["check"] - _, assertExisted := validatorMap["assert"] - _, expectExisted := validatorMap["expect"] - // check priority: HRP > HttpRunner - if checkExisted && assertExisted && expectExisted { - // HRP validator format - validator.Check = validatorMap["check"].(string) - validator.Assert = validatorMap["assert"].(string) - validator.Expect = validatorMap["expect"] - if msg, existed := validatorMap["msg"]; existed { - validator.Message = msg.(string) - } - validator.Check = convertCheckExpr(validator.Check) - Validators[i] = validator - } else if len(validatorMap) == 1 { - // HttpRunner validator format - for assertMethod, iValidatorContent := range validatorMap { - checkAndExpect := iValidatorContent.([]interface{}) - if len(checkAndExpect) != 2 { - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } - validator.Check = checkAndExpect[0].(string) - validator.Assert = assertMethod - validator.Expect = checkAndExpect[1] - } - validator.Check = convertCheckExpr(validator.Check) - Validators[i] = validator - } else { - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } - } - return nil +// ITestCase represents interface for testcases, +// includes TestCase and TestCasePath. +type ITestCase interface { + GetPath() string + ToTestCase() (*TestCase, error) } -func convertCompatTestCase(tc *TCase) (err error) { - defer func() { - if p := recover(); p != nil { - err = fmt.Errorf("convert compat testcase error: %v", p) - } - }() +// TestCase is a container for one testcase, which is used for testcase runner. +// TestCase implements ITestCase interface. +type TestCase struct { + Config *TConfig + TestSteps []IStep +} + +func (tc *TestCase) GetPath() string { + return tc.Config.Path +} + +func (tc *TestCase) ToTestCase() (*TestCase, error) { + return tc, nil +} + +func (tc *TestCase) ToTCase() *TCase { + tCase := &TCase{ + Config: tc.Config, + } for _, step := range tc.TestSteps { - // 1. deal with request body compatible with HttpRunner - if step.Request != nil && step.Request.Body == nil { - if step.Request.Json != nil { - step.Request.Headers["Content-Type"] = "application/json; charset=utf-8" - step.Request.Body = step.Request.Json - } else if step.Request.Data != nil { - step.Request.Body = step.Request.Data - } - } - - // 2. deal with validators compatible with HttpRunner - err = convertCompatValidator(step.Validators) - if err != nil { - return err - } + tCase.TestSteps = append(tCase.TestSteps, step.ToStruct()) } - return nil -} - -// convertCheckExpr deals with check expression including hyphen -func convertCheckExpr(checkExpr string) string { - if strings.Contains(checkExpr, textExtractorSubRegexp) { - return checkExpr - } - checkItems := strings.Split(checkExpr, ".") - for i, checkItem := range checkItems { - if strings.Contains(checkItem, "-") && !strings.Contains(checkItem, "\"") { - checkItems[i] = fmt.Sprintf("\"%s\"", checkItem) - } - } - return strings.Join(checkItems, ".") -} - -func getProjectRootDirPath(path string) (rootDir string, err error) { - pluginPath, err := locatePlugin(path) - if err == nil { - rootDir = filepath.Dir(pluginPath) - return - } - - // failed to locate project root dir - // maybe project plugin debugtalk.xx is not exist - // use current dir instead - return os.Getwd() -} - -// APIPath implements IAPI interface. -type APIPath string - -func (path *APIPath) GetPath() string { - return fmt.Sprintf("%v", *path) -} - -func (path *APIPath) ToAPI() (*API, error) { - api := &API{} - apiPath := path.GetPath() - err := builtin.LoadFile(apiPath, api) - if err != nil { - return nil, err - } - err = convertCompatValidator(api.Validators) - return api, err + return tCase } // TestCasePath implements ITestCase interface. @@ -137,7 +60,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { return nil, err } - err = convertCompatTestCase(tc) + err = tc.makeCompat() if err != nil { return nil, err } @@ -215,3 +138,148 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { } return testCase, nil } + +// TCase represents testcase data structure. +// Each testcase includes one public config and several sequential teststeps. +type TCase struct { + Config *TConfig `json:"config" yaml:"config"` + TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` +} + +// makeCompat converts TCase to compatible testcase +func (tc *TCase) makeCompat() error { + var err error + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("convert compat testcase error: %v", p) + } + }() + for _, step := range tc.TestSteps { + // 1. deal with request body compatible with HttpRunner + if step.Request != nil && step.Request.Body == nil { + if step.Request.Json != nil { + step.Request.Headers["Content-Type"] = "application/json; charset=utf-8" + step.Request.Body = step.Request.Json + } else if step.Request.Data != nil { + step.Request.Body = step.Request.Data + } + } + + // 2. deal with validators compatible with HttpRunner + err = convertCompatValidator(step.Validators) + if err != nil { + return err + } + } + return nil +} + +func convertCompatValidator(Validators []interface{}) (err error) { + for i, iValidator := range Validators { + validatorMap := iValidator.(map[string]interface{}) + validator := Validator{} + _, checkExisted := validatorMap["check"] + _, assertExisted := validatorMap["assert"] + _, expectExisted := validatorMap["expect"] + // check priority: HRP > HttpRunner + if checkExisted && assertExisted && expectExisted { + // HRP validator format + validator.Check = validatorMap["check"].(string) + validator.Assert = validatorMap["assert"].(string) + validator.Expect = validatorMap["expect"] + if msg, existed := validatorMap["msg"]; existed { + validator.Message = msg.(string) + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else if len(validatorMap) == 1 { + // HttpRunner validator format + for assertMethod, iValidatorContent := range validatorMap { + checkAndExpect := iValidatorContent.([]interface{}) + if len(checkAndExpect) != 2 { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + validator.Check = checkAndExpect[0].(string) + validator.Assert = assertMethod + validator.Expect = checkAndExpect[1] + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + } + return nil +} + +// convertCheckExpr deals with check expression including hyphen +func convertCheckExpr(checkExpr string) string { + if strings.Contains(checkExpr, textExtractorSubRegexp) { + return checkExpr + } + checkItems := strings.Split(checkExpr, ".") + for i, checkItem := range checkItems { + if strings.Contains(checkItem, "-") && !strings.Contains(checkItem, "\"") { + checkItems[i] = fmt.Sprintf("\"%s\"", checkItem) + } + } + return strings.Join(checkItems, ".") +} + +func loadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) { + testCases := make([]*TestCase, 0) + + for _, iTestCase := range iTestCases { + if _, ok := iTestCase.(*TestCase); ok { + testcase, err := iTestCase.ToTestCase() + if err != nil { + log.Error().Err(err).Msg("failed to convert ITestCase interface to TestCase struct") + return nil, err + } + testCases = append(testCases, testcase) + continue + } + + // iTestCase should be a TestCasePath, file path or folder path + tcPath, ok := iTestCase.(*TestCasePath) + if !ok { + return nil, errors.New("invalid iTestCase type") + } + + casePath := tcPath.GetPath() + err := fs.WalkDir(os.DirFS(casePath), ".", func(path string, dir fs.DirEntry, e error) error { + if dir == nil { + // casePath is a file other than a dir + path = casePath + } else if dir.IsDir() && path != "." && strings.HasPrefix(path, ".") { + // skip hidden folders + return fs.SkipDir + } else { + // casePath is a dir + path = filepath.Join(casePath, path) + } + + // ignore non-testcase files + ext := filepath.Ext(path) + if ext != ".yml" && ext != ".yaml" && ext != ".json" { + return nil + } + + // filtered testcases + testCasePath := TestCasePath(path) + tc, err := testCasePath.ToTestCase() + if err != nil { + log.Error().Err(err).Str("path", path).Msg("load testcase failed") + return errors.Wrap(err, "load testcase failed") + } + testCases = append(testCases, tc) + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "read dir failed") + } + } + + log.Info().Int("count", len(testCases)).Msg("load testcases successfully") + return testCases, nil +} diff --git a/hrp/convert_test.go b/hrp/testcase_test.go similarity index 100% rename from hrp/convert_test.go rename to hrp/testcase_test.go diff --git a/hrp/validate.go b/hrp/validate.go deleted file mode 100644 index 51150563..00000000 --- a/hrp/validate.go +++ /dev/null @@ -1,223 +0,0 @@ -package hrp - -import ( - "fmt" -) - -// StepRequestValidation implements IStep interface. -type StepRequestValidation struct { - step *TStep -} - -func (s *StepRequestValidation) Name() string { - if s.step.Name != "" { - return s.step.Name - } - return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL) -} - -func (s *StepRequestValidation) Type() string { - return fmt.Sprintf("request-%v", s.step.Request.Method) -} - -func (s *StepRequestValidation) ToStruct() *TStep { - return s.step -} - -func (s *StepRequestValidation) AssertEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertGreater(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "greater_than", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLess(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "less_than", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertGreaterOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "greater_or_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLessOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "less_or_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertNotEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "not_equal", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertContains(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "contains", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertTypeMatch(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "type_match", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertRegexp(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "regex_match", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertStartsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "startswith", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertEndsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "endswith", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLengthEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "length_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertContainedBy(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "contained_by", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLengthLessThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "length_less_than", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "string_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "length_less_or_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLengthGreaterThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "length_greater_than", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -} - -func (s *StepRequestValidation) AssertLengthGreaterOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { - v := Validator{ - Check: jmesPath, - Assert: "length_greater_or_equals", - Expect: expected, - Message: msg, - } - s.step.Validators = append(s.step.Validators, v) - return s -}