refactor: move hrp/ to root folder

This commit is contained in:
lilong.129
2025-02-06 10:52:08 +08:00
parent 9376692b71
commit 1f063dd6f7
221 changed files with 206 additions and 211 deletions

View File

@@ -1,144 +0,0 @@
# 代码阅读指南golang 部分)
## 核心数据结构
HttpRunner 以 `TestCase` 为核心,将任意测试场景抽象为有序步骤的集合。
```go
type TestCase struct {
Config IConfig `json:"config" yaml:"config"`
TestSteps []IStep `json:"teststeps" yaml:"teststeps"`
}
type IConfig interface {
Get() *TConfig
}
```
其中,测试步骤 `IStep` 采用了 `go interface` 的设计理念,支持进行任意拓展;步骤内容统一在 `Run` 方法中进行实现。
```go
type IStep interface {
Name() string
Type() StepType
Config() *StepConfig
Run(*SessionRunner) (*StepResult, error)
}
```
我们只需遵循 `IStep` 的接口定义,即可实现各种类型的测试步骤类型。当前 hrp 已支持的步骤类型包括:
- [request](step_request.go):发起单次 HTTP 请求
- [api](step_api.go):引用执行其它 API 文件
- [testcase](step_testcase.go):引用执行其它测试用例文件
- [thinktime](step_thinktime.go):思考时间,按照配置的逻辑进行等待
- [transaction](step_transaction.go):事务机制,用于压测
- [rendezvous](step_rendezvous.go):集合点机制,用于压测
- [mobile_UI](step_mobile_ui.go):移动端 UI 自动化
- [shell](step_shell.go):执行 shell 命令
基于该机制,我们可以扩展支持新的协议类型,例如 HTTP2/WebSocket/RPC 等;同时也可以支持新的测试类型,例如 UI 自动化。甚至我们还可以在一个测试用例中混合调用多种不同的 Step 类型,例如实现 HTTP/RPC/UI 混合场景。
## 运行主流程
### 整体控制器 HRPRunner
执行接口测试时,会初始化一个 `HRPRunner`,用于控制测试的执行策略。
```go
type HRPRunner struct {
t *testing.T
failfast bool
requestsLogOn bool
pluginLogOn bool
saveTests bool
genHTMLReport bool
client *http.Client
}
func (r *HRPRunner) Run(testcases ...ITestCase) error
func (r *HRPRunner) NewCaseRunner(testcase TestCase) (*CaseRunner, error)
```
重点关注两个方法:
- Run测试执行的主入口支持运行一个或多个测试用例
- NewCaseRunner针对给定的测试用例初始化一个 CaseRunner
### 用例执行器 CaseRunner
针对每个测试用例,采用 CaseRunner 存储其公共信息,包括 plugin/parser
```go
type CaseRunner struct {
TestCase // each testcase init its own CaseRunner
hrpRunner *HRPRunner // all case runners share one HRPRunner
parser *Parser // each CaseRunner init its own Parser
parametersIterator *ParametersIterator
}
func (r *CaseRunner) NewSession() *SessionRunner
```
重点关注一个方法:
- NewSession测试用例的每一次执行对应一个 SessionRunner
### SessionRunner
测试用例的具体执行都由 `SessionRunner` 完成,每个 session 实例中除了包含测试用例自身内容外,还会包含测试过程的 session 数据和最终测试结果 summary。
```go
type SessionRunner struct {
caseRunner *CaseRunner // all session runners share one CaseRunner
sessionVariables map[string]interface{} // testcase execution session variables
summary *TestCaseSummary // record test case summary
}
func (r *SessionRunner) Start(givenVars map[string]interface{}) (summary *TestCaseSummary, err error)
```
重点关注一个方法:
- Start启动执行用例依次执行所有测试步骤
```go
func (r *SessionRunner) Start(givenVars map[string]interface{}) (summary *TestCaseSummary, err error) {
...
r.resetSession()
r.InitWithParameters(givenVars)
defer func() {
summary = r.summary
}
// run step in sequential order
for _, step := range r.testCase.TestSteps {
// parse step
err = r.parseStepStruct(step)
// run step
stepResult, err := step.Run(r)
// update summary
r.summary.Records = append(r.summary.Records, stepResult)
// update extracted variables
for k, v := range stepResult.ExportVars {
r.sessionVariables[k] = v
}
// check if failfast
if err != nil && r.caseRunner.hrpRunner.failfast {
return errors.Wrap(err, "abort running due to failfast setting")
}
}
...
}
```
在主流程中SessionRunner 并不需要关注 step 的具体类型,统一都是调用 `step.Run(r)`,具体实现逻辑都在对应 step 的 `Run(*SessionRunner)` 方法中。

View File

@@ -1,258 +0,0 @@
package hrp
import (
"bufio"
_ "embed"
"fmt"
"html/template"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/httprunner/funplugin/fungo"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/internal/config"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/internal/version"
)
//go:embed internal/scaffold/templates/plugin/debugtalkPythonTemplate
var pyTemplate string
//go:embed internal/scaffold/templates/plugin/debugtalkGoTemplate
var goTemplate string
// regex for finding all function names
type regexFunctions struct {
*regexp.Regexp
}
var (
regexPyFunctionName = regexFunctions{regexp.MustCompile(`(?m)^def ([a-zA-Z_]\w*)\(.*\)`)}
regexGoFunctionName = regexFunctions{regexp.MustCompile(`(?m)^func ([a-zA-Z_]\w*)\(.*\)`)}
)
func (r *regexFunctions) findAllFunctionNames(content string) ([]string, error) {
var functionNames []string
// find all function names
functionNameSlice := r.FindAllStringSubmatch(content, -1)
for _, elem := range functionNameSlice {
name := strings.Trim(elem[1], " ")
functionNames = append(functionNames, name)
}
var filteredFunctionNames []string
if r == &regexPyFunctionName {
// filter private functions
for _, name := range functionNames {
if strings.HasPrefix(name, "__") {
continue
}
filteredFunctionNames = append(filteredFunctionNames, name)
}
} else if r == &regexGoFunctionName {
// filter main and init function
for _, name := range functionNames {
if name == "main" {
log.Warn().Msg("plugin debugtalk.go should not define main() function !!!")
return nil, errors.New("debugtalk.go should not contain main() function")
}
if name == "init" {
continue
}
filteredFunctionNames = append(filteredFunctionNames, name)
}
}
log.Info().Strs("functionNames", filteredFunctionNames).Msg("find all function names")
return filteredFunctionNames, nil
}
type pluginTemplate struct {
path string // file path
Version string // hrp version
FunctionNames []string // function names
}
func (pt *pluginTemplate) generate(tmpl, output string) error {
file, err := os.Create(output)
if err != nil {
log.Error().Err(err).Msg("open output file failed")
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
err = template.Must(template.New("debugtalk").Parse(tmpl)).Execute(writer, pt)
if err != nil {
log.Error().Err(err).Msg("execute template parsing failed")
return err
}
err = writer.Flush()
if err == nil {
log.Info().Str("output", output).Msg("generate debugtalk success")
} else {
log.Error().Str("output", output).Msg("generate debugtalk failed")
}
return err
}
func (pt *pluginTemplate) generatePy(output string) error {
// specify output file path
if output == "" {
output = filepath.Join(config.RootDir, PluginPySourceGenFile)
} else if builtin.IsFolderPathExists(output) {
output = filepath.Join(output, PluginPySourceGenFile)
}
// generate .debugtalk_gen.py
err := pt.generate(pyTemplate, output)
if err != nil {
return err
}
log.Info().Str("output", output).Str("plugin", pt.path).Msg("build python plugin successfully")
return nil
}
func (pt *pluginTemplate) generateGo(output string) error {
pluginDir := filepath.Dir(pt.path)
err := pt.generate(goTemplate, filepath.Join(pluginDir, PluginGoSourceGenFile))
if err != nil {
return errors.Wrap(err, "generate hashicorp plugin failed")
}
// check go sdk in tempDir
if err := myexec.RunCommand("go", "version"); err != nil {
return errors.Wrap(err, "go sdk not installed")
}
if !builtin.IsFilePathExists(filepath.Join(pluginDir, "go.mod")) {
// create go mod
if err := myexec.ExecCommandInDir(myexec.Command("go", "mod", "init", "main"), pluginDir); err != nil {
return err
}
// download plugin dependency
// funplugin version should be locked
funplugin := fmt.Sprintf("github.com/httprunner/funplugin@%s", fungo.Version)
if err := myexec.ExecCommandInDir(myexec.Command("go", "get", funplugin), pluginDir); err != nil {
return errors.Wrap(err, "go get funplugin failed")
}
}
// add missing and remove unused modules
if err := myexec.ExecCommandInDir(myexec.Command("go", "mod", "tidy"), pluginDir); err != nil {
return errors.Wrap(err, "go mod tidy failed")
}
// specify output file path
if output == "" {
output = filepath.Join(config.RootDir, PluginHashicorpGoBuiltFile)
} else if builtin.IsFolderPathExists(output) {
output = filepath.Join(output, PluginHashicorpGoBuiltFile)
}
outputPath, _ := filepath.Abs(output)
// build go plugin to debugtalk.bin
cmd := myexec.Command("go", "build", "-o", outputPath, PluginGoSourceGenFile, filepath.Base(pt.path))
if err := myexec.ExecCommandInDir(cmd, pluginDir); err != nil {
return errors.Wrap(err, "go build plugin failed")
}
log.Info().Str("output", outputPath).Str("plugin", pt.path).Msg("build go plugin successfully")
return nil
}
// buildGo builds debugtalk.go to debugtalk.bin
func buildGo(path string, output string) error {
log.Info().Str("path", path).Str("output", output).Msg("start to build go plugin")
// report GA event
sdk.SendGA4Event("hrp_build_plugin", map[string]interface{}{
"pluginType": "go",
})
content, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("failed to read file")
return errors.Wrap(code.LoadFileError, err.Error())
}
functionNames, err := regexGoFunctionName.findAllFunctionNames(string(content))
if err != nil {
return errors.Wrap(code.InvalidPluginFile, err.Error())
}
templateContent := &pluginTemplate{
path: path,
Version: version.VERSION,
FunctionNames: functionNames,
}
err = templateContent.generateGo(output)
if err != nil {
return errors.Wrap(code.BuildGoPluginFailed, err.Error())
}
return nil
}
// buildPy completes funppy information in debugtalk.py
func buildPy(path string, output string) error {
log.Info().Str("path", path).Str("output", output).Msg("start to prepare python plugin")
// report GA event
sdk.SendGA4Event("hrp_build_plugin", map[string]interface{}{
"pluginType": "python",
})
// check the syntax of debugtalk.py
err := myexec.ExecPython3Command("py_compile", path)
if err != nil {
return errors.Wrap(code.InvalidPluginFile,
fmt.Sprintf("python plugin syntax invalid: %s", err.Error()))
}
content, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("failed to read file")
return errors.Wrap(code.LoadFileError, err.Error())
}
functionNames, err := regexPyFunctionName.findAllFunctionNames(string(content))
if err != nil {
return errors.Wrap(code.InvalidPluginFile, err.Error())
}
templateContent := &pluginTemplate{
path: path,
Version: version.VERSION,
FunctionNames: functionNames,
}
err = templateContent.generatePy(output)
if err != nil {
return errors.Wrap(code.BuildPyPluginFailed, err.Error())
}
return nil
}
func BuildPlugin(path string, output string) (err error) {
ext := filepath.Ext(path)
switch ext {
case ".py":
err = buildPy(path, output)
case ".go":
err = buildGo(path, output)
default:
return errors.Wrap(code.UnsupportedFileExtension,
"type error, expected .py or .go")
}
if err != nil {
log.Error().Err(err).Str("path", path).Msg("build plugin failed")
return err
}
return nil
}

View File

@@ -1,141 +0,0 @@
package hrp
import (
"path/filepath"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRun(t *testing.T) {
err := BuildPlugin(tmpl("plugin/debugtalk.go"), "./debugtalk.bin")
if !assert.Nil(t, err) {
t.Fatal()
}
genDebugTalkPyPath := filepath.Join(tmpl("plugin/"), PluginPySourceGenFile)
err = BuildPlugin(tmpl("plugin/debugtalk.py"), genDebugTalkPyPath)
if !assert.Nil(t, err) {
t.Fatal()
}
contentBytes, err := readFile(genDebugTalkPyPath)
if !assert.Nil(t, err) {
t.Fatal()
}
content := string(contentBytes)
if !assert.Contains(t, content, "import funppy") {
t.Fatal()
}
if !assert.Contains(t, content, "funppy.register") {
t.Fatal()
}
reg, _ := regexp.Compile(`funppy\.register`)
matchedSlice := reg.FindAllStringSubmatch(content, -1)
if !assert.Len(t, matchedSlice, 10) {
t.Fatal()
}
}
func TestFindAllPythonFunctionNames(t *testing.T) {
content := `
def test_1(): # exported function
pass
def _test_2(): # exported function
pass
def __test_3(): # private function
pass
# def test_4(): # commented out function
# pass
def Test5(): # exported function
pass
`
names, err := regexPyFunctionName.findAllFunctionNames(content)
if !assert.Nil(t, err) {
t.FailNow()
}
if !assert.Contains(t, names, "test_1") {
t.FailNow()
}
if !assert.Contains(t, names, "Test5") {
t.FailNow()
}
if !assert.Contains(t, names, "_test_2") {
t.FailNow()
}
if !assert.NotContains(t, names, "__test_3") {
t.FailNow()
}
// commented out function
if !assert.NotContains(t, names, "test_4") {
t.FailNow()
}
}
func TestFindAllGoFunctionNames(t *testing.T) {
content := `
func Test1() { // exported function
return
}
func testFunc2() { // exported function
return
}
func init() { // private function
return
}
func _Test3() { // exported function
return
}
// func Test4() { // commented out function
// return
// }
`
names, err := regexGoFunctionName.findAllFunctionNames(content)
if !assert.Nil(t, err) {
t.FailNow()
}
if !assert.Contains(t, names, "Test1") {
t.FailNow()
}
if !assert.Contains(t, names, "testFunc2") {
t.FailNow()
}
if !assert.NotContains(t, names, "init") {
t.FailNow()
}
if !assert.Contains(t, names, "_Test3") {
t.FailNow()
}
// commented out function
if !assert.NotContains(t, names, "Test4") {
t.FailNow()
}
}
func TestFindAllGoFunctionNamesAbnormal(t *testing.T) {
content := `
func init() { // private function
return
}
func main() { // should not define main() function
return
}
`
_, err := regexGoFunctionName.findAllFunctionNames(content)
if !assert.NotNil(t, err) {
t.FailNow()
}
}

View File

@@ -1,64 +0,0 @@
package adb
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
func format(data map[string]string) string {
result, _ := json.MarshalIndent(data, "", "\t")
return string(result)
}
var listAndroidDevicesCmd = &cobra.Command{
Use: "devices",
Short: "List all Android devices",
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_adb_devices", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
deviceList, err := uixt.GetAndroidDevices(serial)
if err != nil {
fmt.Println(err)
os.Exit(0)
}
for _, d := range deviceList {
if isDetail {
fmt.Println(format(d.DeviceInfo()))
} else {
if usb, err := d.Usb(); err != nil {
fmt.Println(d.Serial())
} else {
fmt.Println(d.Serial(), usb)
}
}
}
return nil
},
}
var (
serial string
isDetail bool
)
func init() {
listAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
listAndroidDevicesCmd.Flags().BoolVarP(&isDetail, "detail", "d", false, "print device's detail")
androidRootCmd.AddCommand(listAndroidDevicesCmd)
}

View File

@@ -1,31 +0,0 @@
package adb
import (
"fmt"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/pkg/gadb"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
var androidRootCmd = &cobra.Command{
Use: "adb",
Short: "simple utils for android device management",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
}
func getDevice(serial string) (*gadb.Device, error) {
devices, err := uixt.GetAndroidDevices(serial)
if err != nil {
return nil, err
}
if len(devices) > 1 {
return nil, fmt.Errorf("found multiple attached devices, please specify android serial")
}
return devices[0], nil
}
func Init(rootCmd *cobra.Command) {
rootCmd.AddCommand(androidRootCmd)
}

View File

@@ -1,69 +0,0 @@
package adb
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
var (
replace bool
downgrade bool
grant bool
)
var installCmd = &cobra.Command{
Use: "install [flags] PACKAGE",
Short: "push package to the device and install them automatically",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_adb_devices", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
_, err = getDevice(serial)
if err != nil {
return err
}
device, err := uixt.NewAndroidDevice(uixt.WithSerialNumber(serial))
if err != nil {
fmt.Println(err)
return err
}
driverExt, err := device.NewDriver()
if err != nil {
fmt.Println(err)
return err
}
err = driverExt.Install(args[0],
uixt.WithReinstall(replace),
uixt.WithDowngrade(downgrade),
uixt.WithGrantPermission(grant),
)
if err != nil {
fmt.Println(err)
return err
}
fmt.Println("success")
return nil
},
}
func init() {
installCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
installCmd.Flags().BoolVarP(&replace, "replace", "r", false, "replace existing application")
installCmd.Flags().BoolVarP(&downgrade, "downgrade", "d", false, "allow version code downgrade (debuggable packages only)")
installCmd.Flags().BoolVarP(&grant, "grant", "g", false, "grant all runtime permissions")
androidRootCmd.AddCommand(installCmd)
}

View File

@@ -1,50 +0,0 @@
package adb
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var screencapAndroidDevicesCmd = &cobra.Command{
Use: "screencap",
Short: "Start android screen capture",
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_adb_screencap", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
device, err := getDevice(serial)
if err != nil {
return err
}
res, err := device.ScreenCap()
if err != nil {
return err
}
filepath := fmt.Sprintf("%s.png", builtin.GenNameWithTimestamp("screencap_%d"))
if err = os.WriteFile(filepath, res, 0o644); err != nil {
return err
}
fmt.Println("screencap saved to", filepath)
return nil
},
}
func init() {
screencapAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
androidRootCmd.AddCommand(screencapAndroidDevicesCmd)
}

View File

@@ -1,39 +0,0 @@
package cmd
import (
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var buildCmd = &cobra.Command{
Use: "build $path ...",
Short: "build plugin for testing",
Long: `build python/go plugin for testing`,
Example: ` $ hrp build plugin/debugtalk.go
$ hrp build plugin/debugtalk.py`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_build", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
return hrp.BuildPlugin(args[0], output)
},
}
var output string
func init() {
rootCmd.AddCommand(buildCmd)
buildCmd.Flags().StringVarP(&output, "output", "o", "", "funplugin product output path, default: cwd")
}

View File

@@ -1,26 +0,0 @@
package main
import (
"os"
"time"
"github.com/getsentry/sentry-go"
"github.com/httprunner/httprunner/v5/hrp/cmd"
)
func main() {
defer func() {
if err := recover(); err != nil {
// report panic to sentry
sentry.CurrentHub().Recover(err)
sentry.Flush(time.Second * 5)
// print panic trace
panic(err)
}
}()
exitCode := cmd.Execute()
os.Exit(exitCode)
}

View File

@@ -1,122 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/pkg/convert"
)
var convertCmd = &cobra.Command{
Use: "convert $path...",
Short: "convert multiple source format to HttpRunner JSON/YAML/gotest/pytest cases",
Args: cobra.MinimumNArgs(1),
SilenceUsage: false,
RunE: func(cmd *cobra.Command, args []string) error {
caseConverter := convert.NewConverter(outputDir, profilePath)
var fromType convert.FromType
if fromYAMLFlag {
fromType = convert.FromTypeYAML
} else if fromPostmanFlag {
fromType = convert.FromTypePostman
} else if fromHARFlag {
fromType = convert.FromTypeHAR
} else if fromCurlFlag {
fromType = convert.FromTypeCurl
} else {
fromType = convert.FromTypeJSON
log.Info().Str("fromType", fromType.String()).Msg("set default")
}
var outputType convert.OutputType
if toYAMLFlag {
outputType = convert.OutputTypeYAML
} else if toPyTestFlag {
packages := []string{"httprunner"}
_, err := myexec.EnsurePython3Venv(venv, packages...)
if err != nil {
log.Error().Err(err).Msg("python3 venv is not ready")
return errors.Wrap(code.InvalidPython3Venv, err.Error())
}
outputType = convert.OutputTypePyTest
} else {
outputType = convert.OutputTypeJSON
log.Info().Str("outputType", outputType.String()).Msg("set default")
}
var files []string
for _, arg := range args {
if builtin.IsFolderPathExists(arg) {
fs, err := os.ReadDir(arg)
if err != nil {
log.Error().Err(err).Str("path", arg).Msg("read dir failed")
continue
}
for _, f := range fs {
files = append(files, filepath.Join(arg, f.Name()))
}
} else {
files = append(files, arg)
}
}
for _, file := range files {
extName := filepath.Ext(file)
if !builtin.Contains(fromType.Extensions(), extName) {
log.Warn().Str("path", file).
Strs("expectExtensions", fromType.Extensions()).
Msg("skip file")
continue
}
if err := caseConverter.Convert(file, fromType, outputType); err != nil {
log.Error().Err(err).Str("path", file).
Str("outputType", outputType.String()).
Msg("convert case failed")
}
}
return nil
},
}
var (
outputDir string
profilePath string
fromJSONFlag bool
fromYAMLFlag bool
fromPostmanFlag bool
fromHARFlag bool
fromCurlFlag bool
toJSONFlag bool
toYAMLFlag bool
toPyTestFlag bool
)
func init() {
rootCmd.AddCommand(convertCmd)
convertCmd.Flags().BoolVar(&fromJSONFlag, "from-json", true, "load from json case format")
convertCmd.Flags().BoolVar(&fromYAMLFlag, "from-yaml", false, "load from yaml case format")
convertCmd.Flags().BoolVar(&fromHARFlag, "from-har", false, "load from HAR format")
convertCmd.Flags().BoolVar(&fromPostmanFlag, "from-postman", false, "load from postman format")
convertCmd.Flags().BoolVar(&fromCurlFlag, "from-curl", false, "load from curl format")
convertCmd.Flags().BoolVar(&toJSONFlag, "to-json", true, "convert to JSON case scripts")
convertCmd.Flags().BoolVar(&toYAMLFlag, "to-yaml", false, "convert to YAML case scripts")
convertCmd.Flags().BoolVar(&toPyTestFlag, "to-pytest", false, "convert to pytest scripts")
convertCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory")
convertCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies")
}

View File

@@ -1,15 +0,0 @@
package cmd
import (
"testing"
"github.com/spf13/cobra/doc"
)
// run this test to generate markdown docs for hrp command
func TestGenMarkdownTree(t *testing.T) {
err := doc.GenMarkdownTree(rootCmd, "../../docs/cmd")
if err != nil {
t.Fatal(err)
}
}

View File

@@ -1,75 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
type Application struct {
CFBundleVersion string `json:"version"`
CFBundleDisplayName string `json:"name"`
CFBundleIdentifier string `json:"bundleId"`
}
var listAppsCmd = &cobra.Command{
Use: "apps",
Short: "List all iOS installed apps",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_apps", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
device, err := getDevice(udid)
if err != nil {
return err
}
device.GetDeviceInfo()
var applicationType uixt.ApplicationType
switch appType {
case "user":
applicationType = uixt.ApplicationTypeUser
case "system":
applicationType = uixt.ApplicationTypeSystem
case "internal":
applicationType = uixt.ApplicationTypeInternal
case "all":
applicationType = uixt.ApplicationTypeAny
}
result, err := device.ListApps(applicationType)
if err != nil {
return fmt.Errorf("get app list failed %v", err)
}
for _, app := range result {
a := Application{}
mapstructure.Decode(app, &a)
fmt.Printf("%-30.30s %-50.50s %-s\n",
a.CFBundleDisplayName, a.CFBundleIdentifier, a.CFBundleVersion)
}
return nil
},
}
var appType string
func init() {
listAppsCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
listAppsCmd.Flags().StringVarP(&appType, "type", "t", "user", "filter application type [user|system|internal|all]")
iosRootCmd.AddCommand(listAppsCmd)
}

View File

@@ -1,81 +0,0 @@
package ios
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/danielpaulus/go-ios/ios"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
type Device struct {
d ios.DeviceEntry
UDID string `json:"UDID"`
Status string `json:"status"`
ConnectionType string `json:"connectionType"`
ConnectionSpeed int `json:"connectionSpeed"`
DeviceDetail *uixt.DeviceDetail `json:"deviceDetail,omitempty"`
}
func (device *Device) GetStatus() string {
if device.ConnectionType != "" {
return "online"
} else {
return "offline"
}
}
func (device *Device) ToFormat() string {
result, _ := json.MarshalIndent(device, "", "\t")
return string(result)
}
var listDevicesCmd = &cobra.Command{
Use: "devices",
Short: "List all iOS devices",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_devices", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
devices, err := uixt.GetIOSDevices(udid)
if err != nil {
fmt.Println(err)
os.Exit(0)
}
for _, d := range devices {
deviceProperties := d.Properties
device := &Device{
d: d,
UDID: deviceProperties.SerialNumber,
ConnectionType: deviceProperties.ConnectionType,
ConnectionSpeed: deviceProperties.ConnectionSpeed,
}
device.Status = device.GetStatus()
fmt.Println(device.UDID, device.ConnectionType, device.Status)
}
return nil
},
}
var udid string
func init() {
listDevicesCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's udid")
iosRootCmd.AddCommand(listDevicesCmd)
}

View File

@@ -1,24 +0,0 @@
package ios
import (
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
var iosRootCmd = &cobra.Command{
Use: "ios",
Short: "simple utils for ios device management",
}
func getDevice(udid string) (*uixt.IOSDevice, error) {
device, err := uixt.NewIOSDevice(uixt.WithUDID(udid))
if err != nil {
return nil, err
}
return device, nil
}
func Init(rootCmd *cobra.Command) {
rootCmd.AddCommand(iosRootCmd)
}

View File

@@ -1,52 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
var installCmd = &cobra.Command{
Use: "install [flags] PACKAGE",
Short: "push package to the device and install them automatically",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_adb_devices", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
_, err = getDevice(udid)
if err != nil {
return err
}
device, err := uixt.NewIOSDevice(uixt.WithUDID(udid))
if err != nil {
fmt.Println(err)
return err
}
err = device.Install(args[0])
if err != nil {
fmt.Println(err)
return err
}
fmt.Println("success")
return nil
},
}
func init() {
installCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's serial")
iosRootCmd.AddCommand(installCmd)
}

View File

@@ -1,14 +0,0 @@
//go:build localtest
package ios
import "testing"
func TestGetDevice(t *testing.T) {
device, err := getDevice(udid)
if err != nil {
t.Fatal(err)
}
t.Logf("device: %v", device)
}

View File

@@ -1,77 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
// mountCmd represents the mount command
var mountCmd = &cobra.Command{
Use: "mount",
Short: "A brief description of your command",
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_mount", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
device, err := getDevice(udid)
if err != nil {
return err
}
images, errImage := device.ListImages()
if err != nil {
return fmt.Errorf("list device images failed: %v", err)
}
if listDeveloperDiskImage {
for i, imgSign := range images {
fmt.Printf("[%d] %s\n", i+1, imgSign)
}
return nil
}
if errImage == nil && len(images) > 0 {
log.Info().Strs("images", images).Msg("ios developer image is already mounted")
return nil
}
log.Info().Str("dir", developerDiskImageDir).Msg("start to mount ios developer image")
if !builtin.IsFolderPathExists(developerDiskImageDir) {
return fmt.Errorf("developer disk image directory not exist: %s", developerDiskImageDir)
}
if err = device.AutoMountImage(developerDiskImageDir); err != nil {
return fmt.Errorf("mount developer disk image failed: %s", err)
}
log.Info().Msg("mount developer disk image successfully")
return nil
},
}
const defaultDeveloperDiskImageDir = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/"
var (
developerDiskImageDir string
listDeveloperDiskImage bool
)
func init() {
mountCmd.Flags().BoolVar(&listDeveloperDiskImage, "list", false, "list developer disk images")
mountCmd.Flags().StringVarP(&developerDiskImageDir, "dir", "d", defaultDeveloperDiskImageDir, "specify developer disk image directory")
mountCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
iosRootCmd.AddCommand(mountCmd)
}

View File

@@ -1,50 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var psCmd = &cobra.Command{
Use: "ps",
Short: "show running processes",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_ps", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
device, err := getDevice(udid)
if err != nil {
return err
}
runningProcesses, err := device.ListProcess(!isAll)
if err != nil {
return err
}
for _, p := range runningProcesses {
fmt.Printf("%4d %-"+fmt.Sprintf("%d", len(runningProcesses))+"s %20s %s\n",
p.Pid, p.Name, time.Since(p.StartDate).String(), bundleID)
}
return nil
},
}
var isAll bool
func init() {
psCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
psCmd.Flags().BoolVarP(&isAll, "all", "a", false, "print all processes including system processes")
iosRootCmd.AddCommand(psCmd)
}

View File

@@ -1,44 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var rebootCmd = &cobra.Command{
Use: "reboot",
Short: "reboot ios device",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_reboot", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
device, err := getDevice(udid)
if err != nil {
return err
}
err = device.Reboot()
if err != nil {
return err
}
fmt.Printf("reboot %s success\n", device.UDID)
return nil
},
}
func init() {
rebootCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
iosRootCmd.AddCommand(rebootCmd)
}

View File

@@ -1,59 +0,0 @@
package ios
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
var uninstallCmd = &cobra.Command{
Use: "uninstall [flags] PACKAGE",
Short: "uninstall package automatically",
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_adb_devices", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
if len(bundleId) == 0 {
return fmt.Errorf("bundleId is empty")
}
_, err = getDevice(udid)
if err != nil {
return err
}
device, err := uixt.NewIOSDevice(uixt.WithUDID(udid))
if err != nil {
fmt.Println(err)
return err
}
err = device.Uninstall(bundleId)
if err != nil {
fmt.Println(err)
return err
}
fmt.Println("success")
return nil
},
}
var bundleId string
func init() {
uninstallCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's serial")
uninstallCmd.Flags().StringVarP(&bundleId, "bundleId", "b", "", "bundleId to uninstall")
iosRootCmd.AddCommand(uninstallCmd)
}

View File

@@ -1,56 +0,0 @@
package ios
import (
"context"
"fmt"
"strings"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var xctestCmd = &cobra.Command{
Use: "xctest",
Short: "run xctest",
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_ios_xctest", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
if bundleID == "" {
return fmt.Errorf("bundleID is required")
}
device, err := getDevice(udid)
if err != nil {
return err
}
err = device.RunXCTest(context.Background(), bundleID, testRunnerBundleID, xctestConfig)
if err != nil {
return errors.Wrap(err, "run xctest failed")
}
return nil
},
}
var (
bundleID string
testRunnerBundleID string
xctestConfig string
)
func init() {
xctestCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify ios device's UDID")
xctestCmd.Flags().StringVarP(&bundleID, "bundleID", "b", "com.gtf.wda.runner.xctrunner", "specify ios bundleID")
xctestCmd.Flags().StringVarP(&testRunnerBundleID, "testRunnerBundleID", "t", "com.gtf.wda.runner.xctrunner", "specify ios testRunnerBundleID")
xctestCmd.Flags().StringVarP(&xctestConfig, "xctestConfig", "x", "GtfWdaRunner.xctest", "specify ios xctestConfig")
iosRootCmd.AddCommand(xctestCmd)
}

View File

@@ -1,44 +0,0 @@
package cmd
import (
"strings"
"time"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/pytest"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
)
var pytestCmd = &cobra.Command{
Use: "pytest $path ...",
Short: "run API test with pytest",
Args: cobra.MinimumNArgs(1),
DisableFlagParsing: true, // allow to pass any args to pytest
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_pytest", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
packages := []string{"httprunner"}
_, err = myexec.EnsurePython3Venv(venv, packages...)
if err != nil {
log.Error().Err(err).Msg("python3 venv is not ready")
return errors.Wrap(code.InvalidPython3Venv, err.Error())
}
return pytest.RunPytest(args)
},
}
func init() {
rootCmd.AddCommand(pytestCmd)
}

View File

@@ -1,59 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/cmd/adb"
"github.com/httprunner/httprunner/v5/hrp/cmd/ios"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/version"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "hrp",
Short: "Next-Generation API Testing Solution.",
Long: `
██╗ ██╗████████╗████████╗██████╗ ██████╗ ██╗ ██╗███╗ ██╗███╗ ██╗███████╗██████╗
██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║████╗ ██║██╔════╝██╔══██╗
███████║ ██║ ██║ ██████╔╝██████╔╝██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██████╔╝
██╔══██║ ██║ ██║ ██╔═══╝ ██╔══██╗██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██╔══██╗
██║ ██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██║ ╚████║███████╗██║ ██║
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝
HttpRunner is an open source API testing tool that supports HTTP(S)/HTTP2/WebSocket/RPC
network protocols, covering API testing, performance testing and digital experience
monitoring (DEM) test types. Enjoy! ✨ 🚀 ✨
License: Apache-2.0
Website: https://httprunner.com
Github: https://github.com/httprunner/httprunner
Copyright 2017 debugtalk`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
hrp.InitLogger(logLevel, logJSON)
},
Version: version.VERSION,
TraverseChildren: true, // parses flags on all parents before executing child command
SilenceUsage: true, // silence usage when an error occurs
}
var (
logLevel string
logJSON bool
venv string
)
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() int {
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "INFO", "set log level")
rootCmd.PersistentFlags().BoolVar(&logJSON, "log-json", false, "set log to json format (default colorized console)")
rootCmd.PersistentFlags().StringVar(&venv, "venv", "", "specify python3 venv path")
ios.Init(rootCmd)
adb.Init(rootCmd)
err := rootCmd.Execute()
return code.GetErrorCode(err)
}

View File

@@ -1,76 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp"
)
// runCmd represents the run command
var runCmd = &cobra.Command{
Use: "run $path...",
Short: "run API test with go engine",
Long: `run yaml/json testcase files for API test`,
Example: ` $ hrp run demo.json # run specified json testcase file
$ hrp run demo.yaml # run specified yaml testcase file
$ hrp run examples/ # run testcases in specified folder`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var paths []hrp.ITestCase
for _, arg := range args {
path := hrp.TestCasePath(arg)
paths = append(paths, &path)
}
runner := makeHRPRunner()
return runner.Run(paths...)
},
}
var (
continueOnFailure bool
requestsLogOff bool
httpStatOn bool
pluginLogOn bool
proxyUrl string
saveTests bool
genHTMLReport bool
caseTimeout float32
)
func init() {
rootCmd.AddCommand(runCmd)
runCmd.Flags().BoolVarP(&continueOnFailure, "continue-on-failure", "c", false, "continue running next step when failure occurs")
runCmd.Flags().BoolVar(&requestsLogOff, "log-requests-off", false, "turn off request & response details logging")
runCmd.Flags().BoolVar(&httpStatOn, "http-stat", false, "turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)")
runCmd.Flags().BoolVar(&pluginLogOn, "log-plugin", false, "turn on plugin logging")
runCmd.Flags().StringVarP(&proxyUrl, "proxy-url", "p", "", "set proxy url")
runCmd.Flags().BoolVarP(&saveTests, "save-tests", "s", false, "save tests summary")
runCmd.Flags().BoolVarP(&genHTMLReport, "gen-html-report", "g", false, "generate html report")
runCmd.Flags().Float32Var(&caseTimeout, "case-timeout", 3600, "set testcase timeout (seconds)")
}
func makeHRPRunner() *hrp.HRPRunner {
runner := hrp.NewRunner(nil).
SetFailfast(!continueOnFailure).
SetSaveTests(saveTests).
SetCaseTimeout(caseTimeout)
if genHTMLReport {
runner.GenHTMLReport()
}
if !requestsLogOff {
runner.SetRequestsLogOn()
}
if httpStatOn {
runner.SetHTTPStatOn()
}
if pluginLogOn {
runner.SetPluginLogOn()
}
if venv != "" {
runner.SetPython3Venv(venv)
}
if proxyUrl != "" {
runner.SetProxyUrl(proxyUrl)
}
return runner
}

View File

@@ -1,58 +0,0 @@
package cmd
import (
"errors"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/scaffold"
)
var scaffoldCmd = &cobra.Command{
Use: "startproject $project_name",
Aliases: []string{"scaffold"},
Short: "create a scaffold project",
Args: cobra.ExactValidArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !ignorePlugin && !genPythonPlugin && !genGoPlugin {
return errors.New("please specify function plugin type")
}
var pluginType scaffold.PluginType
if empty {
pluginType = scaffold.Empty
} else if ignorePlugin {
pluginType = scaffold.Ignore
} else if genGoPlugin {
pluginType = scaffold.Go
} else {
pluginType = scaffold.Py // default
}
err := scaffold.CreateScaffold(args[0], pluginType, venv, force)
if err != nil {
log.Error().Err(err).Msg("create scaffold project failed")
return err
}
log.Info().Str("projectName", args[0]).Msg("create scaffold success")
return nil
},
}
var (
empty bool
ignorePlugin bool
genPythonPlugin bool
genGoPlugin bool
force bool
)
func init() {
rootCmd.AddCommand(scaffoldCmd)
scaffoldCmd.Flags().BoolVarP(&force, "force", "f", false, "force to overwrite existing project")
scaffoldCmd.Flags().BoolVar(&genPythonPlugin, "py", true, "generate hashicorp python plugin")
scaffoldCmd.Flags().BoolVar(&genGoPlugin, "go", false, "generate hashicorp go plugin")
scaffoldCmd.Flags().BoolVar(&ignorePlugin, "ignore-plugin", false, "ignore function plugin")
scaffoldCmd.Flags().BoolVar(&empty, "empty", false, "generate empty project")
}

View File

@@ -1,25 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/pkg/server"
)
// serverCmd represents the server command
var serverCmd = &cobra.Command{
Use: "server start",
Short: "start hrp server",
Long: `start hrp server. exec automation by http`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return server.NewServer(port)
},
}
var port int
func init() {
rootCmd.AddCommand(serverCmd)
serverCmd.Flags().IntVarP(&port, "port", "p", 8082, "Port to run the server on")
}

View File

@@ -1,32 +0,0 @@
package cmd
import (
"strings"
"time"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/internal/wiki"
)
var wikiCmd = &cobra.Command{
Use: "wiki",
Aliases: []string{"info", "docs", "doc"},
Short: "visit https://httprunner.com",
RunE: func(cmd *cobra.Command, args []string) (err error) {
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_wiki", map[string]interface{}{
"args": strings.Join(args, "-"),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
return wiki.OpenWiki()
},
}
func init() {
rootCmd.AddCommand(wikiCmd)
}

View File

@@ -1,243 +0,0 @@
package code
import (
"github.com/pkg/errors"
)
// general: [0, 2)
const (
Success = 0
GeneralFail = 1
)
// environment: [2, 10)
var (
ConfigureError = errors.New("configure error") // 3
UnauthorizedError = errors.New("unauthorized error") // 4
NetworkError = errors.New("network error") // 5
NetworkConfigureError = errors.New("network configure error") // 6
PanicError = errors.New("panic error") // 7
InvalidPython3Venv = errors.New("prepare python3 venv failed") // 9
)
// loader: [10, 20)
var (
LoadFileError = errors.New("load file error") // 10
LoadJSONError = errors.New("load json error") // 11
LoadYAMLError = errors.New("load yaml error") // 12
LoadEnvError = errors.New("load .env error") // 13
LoadCSVError = errors.New("load csv error") // 14
InvalidCaseError = errors.New("invalid case error") // 15
UnsupportedFileExtension = errors.New("unsupported file extension") // 16
ReferencedFileNotFound = errors.New("referenced file not found") // 17
InvalidPluginFile = errors.New("invalid plugin file") // 18
InvalidParamError = errors.New("invalid param error") // 19
)
// parser: [20, 30)
var (
ParseError = errors.New("parse error") // 20
VariableNotFound = errors.New("variable not found") // 21
ParseFunctionError = errors.New("parse function failed") // 22
CallFunctionError = errors.New("call function failed") // 23
ParseVariablesError = errors.New("parse variables failed") // 24
FFmpegError = errors.New("ffmpeg error") // 25
FFprobeError = errors.New("ffprobe error") // 26
)
// runner: [30, 40)
var (
StartRunnerFailed = errors.New("start runner failed") // 30
InitPluginFailed = errors.New("init plugin failed") // 31
BuildGoPluginFailed = errors.New("build go plugin failed") // 32
BuildPyPluginFailed = errors.New("build py plugin failed") // 33
MaxRetryError = errors.New("max retry error") // 37
InterruptError = errors.New("interrupt error") // 38
TimeoutError = errors.New("timeout error") // 39
)
// summary: [40, 50)
var (
GenSummaryFailed = errors.New("generate summary failed") // 40
CollectValidResultFailed = errors.New("no valid result") // 44
DownloadFailed = errors.New("download failed") // 48
UploadFailed = errors.New("upload failed") // 49
)
// device related: [50, 70)
var (
DeviceConnectionError = errors.New("device general connection error") // 50
DeviceHTTPDriverError = errors.New("device HTTP driver error") // 51
DeviceUSBDriverError = errors.New("device USB driver error") // 52
DeviceAppNotInstalled = errors.New("device app not installed") // 59
DeviceGetInfoError = errors.New("device get info error") // 60
DeviceConfigureError = errors.New("device configure error") // 61
DeviceShellExecError = errors.New("device shell exec error") // 62
DeviceOfflineError = errors.New("device offline") // 63
DeviceInstallFailed = errors.New("device install app failed") // 64
DeviceScreenShotError = errors.New("device screenshot error") // 65
DeviceCaptureLogError = errors.New("device capture log error") // 66
DeviceUIResponseSlow = errors.New("device UI response slow") // 67
)
// UI automation related: [70, 80)
var (
MobileUIDriverAppNotInstalled = errors.New("mobile UI driver app not installed") // 68
MobileUIDriverAppCrashed = errors.New("mobile UI driver app crashed") // 69
MobileUIDriverError = errors.New("mobile UI driver error") // 70
MobileUILaunchAppError = errors.New("mobile UI launch app error") // 71
MobileUITapError = errors.New("mobile UI tap error") // 72
MobileUISwipeError = errors.New("mobile UI swipe error") // 73
MobileUIInputError = errors.New("mobile UI input error") // 74
MobileUIValidationError = errors.New("mobile UI validation error") // 75
MobileUIAssertForegroundAppError = errors.New("mobile UI assert foreground app error") // 76
MobileUIAssertForegroundActivityError = errors.New("mobile UI assert foreground activity error") // 77
MobileUIPopupError = errors.New("mobile UI popup error") // 78
LoopActionNotFoundError = errors.New("loop action not found error") // 79
)
// AI related: [80, 90)
var (
CVEnvMissedError = errors.New("CV env missed error") // 80
CVRequestError = errors.New("CV prepare request error") // 81
CVServiceConnectionError = errors.New("CV service connect error") // 82
CVResponseError = errors.New("CV parse response error") // 83
CVResultNotFoundError = errors.New("CV result not found") // 84
StateUnknowError = errors.New("detect state failed") // 85
)
// trackings related: [90, 100)
var (
TrackingGetError = errors.New("get trackings failed") // 90
TrackingFomatError = errors.New("tracking format error") // 91
)
// risk control related: [100, 110)
var (
RiskControlLogout = errors.New("risk control logout") // 100
RiskControlSlideVerification = errors.New("risk control slide verification") // 101
RiskControlAccountActivation = errors.New("risk control account activation") // 102
)
var errorsMap = map[error]int{
// environment
ConfigureError: 3,
UnauthorizedError: 4,
NetworkError: 5,
NetworkConfigureError: 6,
PanicError: 7,
InvalidPython3Venv: 9,
// loader
LoadFileError: 10,
LoadJSONError: 11,
LoadYAMLError: 12,
LoadEnvError: 13,
LoadCSVError: 14,
InvalidCaseError: 15,
UnsupportedFileExtension: 16,
ReferencedFileNotFound: 17,
InvalidPluginFile: 18,
InvalidParamError: 19,
// parser
ParseError: 20,
VariableNotFound: 21,
ParseFunctionError: 22,
CallFunctionError: 23,
ParseVariablesError: 24,
FFmpegError: 25,
FFprobeError: 26,
// runner
StartRunnerFailed: 30,
InitPluginFailed: 31,
BuildGoPluginFailed: 32,
BuildPyPluginFailed: 33,
MaxRetryError: 37,
InterruptError: 38,
TimeoutError: 39,
// summary
GenSummaryFailed: 40,
CollectValidResultFailed: 44,
DownloadFailed: 48,
UploadFailed: 49,
// device related
DeviceConnectionError: 50,
DeviceHTTPDriverError: 51,
DeviceUSBDriverError: 52,
DeviceAppNotInstalled: 59,
DeviceGetInfoError: 60,
DeviceConfigureError: 61,
DeviceShellExecError: 62,
DeviceOfflineError: 63,
DeviceInstallFailed: 64,
DeviceScreenShotError: 65,
DeviceCaptureLogError: 66,
DeviceUIResponseSlow: 67,
// UI automation related
MobileUIDriverAppNotInstalled: 68,
MobileUIDriverAppCrashed: 69,
MobileUIDriverError: 70,
MobileUILaunchAppError: 71,
MobileUITapError: 72,
MobileUISwipeError: 73,
MobileUIInputError: 74,
MobileUIValidationError: 75,
MobileUIAssertForegroundAppError: 76,
MobileUIAssertForegroundActivityError: 77,
MobileUIPopupError: 78,
LoopActionNotFoundError: 79,
// AI related
CVEnvMissedError: 80,
CVRequestError: 81,
CVServiceConnectionError: 82,
CVResponseError: 83,
CVResultNotFoundError: 84,
StateUnknowError: 85,
// trackings related
TrackingGetError: 90,
TrackingFomatError: 91,
// risk control related
RiskControlLogout: 100,
RiskControlSlideVerification: 101,
RiskControlAccountActivation: 102,
}
func IsErrorPredefined(err error) bool {
_, ok := errorsMap[errors.Cause(err)]
return ok
}
func GetErrorCode(err error) (errCode int) {
if err == nil {
return Success
}
e := errors.Cause(err)
if code, ok := errorsMap[e]; ok {
errCode = code
} else {
errCode = GeneralFail
}
return
}
func GetErrorByCode(errCode int) error {
if errCode < 0 {
return nil
}
for key, value := range errorsMap {
if value == errCode {
return key
}
}
return nil
}

View File

@@ -1,18 +0,0 @@
package code
import (
"fmt"
"testing"
)
func TestGetErrorCode(t *testing.T) {
err := LoadYAMLError
code := GetErrorCode(err)
fmt.Println(code)
}
func TestGetErrorByCode(t *testing.T) {
code := 0
err := GetErrorByCode(code)
fmt.Println("[TestGetErrorByCode]:err:",err)
}

View File

@@ -1,173 +0,0 @@
package hrp
import (
"fmt"
"strings"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
"github.com/pkg/errors"
)
// ConvertCaseCompatibility converts TestCase compatible with Golang engine style
func ConvertCaseCompatibility(tc *TestCaseDef) (err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("[MakeCompat] convert compat testcase error: %v", p)
}
}()
for _, step := range tc.Steps {
// 1. deal with request body compatibility
convertCompatRequestBody(step.Request)
// 2. deal with validators compatibility
err = convertCompatValidator(step.Validators)
if err != nil {
return err
}
// 3. deal with extract expr including hyphen
convertExtract(step.Extract)
// 4. deal with mobile step compatibility
if step.Android != nil {
convertCompatMobileStep(step.Android)
} else if step.IOS != nil {
convertCompatMobileStep(step.IOS)
} else if step.Harmony != nil {
convertCompatMobileStep(step.Harmony)
}
}
return nil
}
func convertCompatRequestBody(request *Request) {
if request != nil && request.Body == nil {
if request.Json != nil {
if request.Headers == nil {
request.Headers = make(map[string]string)
}
request.Headers["Content-Type"] = "application/json; charset=utf-8"
request.Body = request.Json
request.Json = nil
} else if request.Data != nil {
request.Body = request.Data
request.Data = nil
}
}
}
func convertCompatValidator(Validators []interface{}) (err error) {
for i, iValidator := range Validators {
if _, ok := iValidator.(Validator); ok {
continue
}
var validatorMap map[string]interface{}
if v, ok := iValidator.(map[string]interface{}); ok {
validatorMap = v
} else if v, ok := iValidator.(map[interface{}]interface{}); ok {
// convert map[interface{}]interface{} to map[string]interface{}
validatorMap = make(map[string]interface{})
for key, value := range v {
strKey := fmt.Sprintf("%v", key)
validatorMap[strKey] = value
}
} else {
return errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("unexpected validator format: %v", iValidator))
}
validator := Validator{}
iCheck, checkExisted := validatorMap["check"]
iAssert, assertExisted := validatorMap["assert"]
iExpect, expectExisted := validatorMap["expect"]
// validator check priority: Golang > Python engine style
if checkExisted && assertExisted && expectExisted {
// Golang engine style
validator.Check = iCheck.(string)
validator.Assert = iAssert.(string)
validator.Expect = iExpect
if iMsg, msgExisted := validatorMap["msg"]; msgExisted {
validator.Message = iMsg.(string)
}
validator.Check = convertJmespathExpr(validator.Check)
Validators[i] = validator
continue
}
if len(validatorMap) == 1 {
// Python engine style
for assertMethod, iValidatorContent := range validatorMap {
validatorContent := iValidatorContent.([]interface{})
if len(validatorContent) > 3 {
return errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("unexpected validator format: %v", validatorMap))
}
validator.Check = validatorContent[0].(string)
validator.Assert = assertMethod
validator.Expect = validatorContent[1]
if len(validatorContent) == 3 {
validator.Message = validatorContent[2].(string)
}
}
validator.Check = convertJmespathExpr(validator.Check)
Validators[i] = validator
continue
}
return errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("unexpected validator format: %v", validatorMap))
}
return nil
}
// convertExtract deals with extract expr including hyphen
func convertExtract(extract map[string]string) {
for key, value := range extract {
extract[key] = convertJmespathExpr(value)
}
}
func convertCompatMobileStep(mobileUI *MobileUI) {
if mobileUI == nil {
return
}
for i := 0; i < len(mobileUI.Actions); i++ {
ma := mobileUI.Actions[i]
actionOptions := uixt.NewActionOptions(ma.GetOptions()...)
// append tap_cv params to screenshot_with_ui_types option
if ma.Method == uixt.ACTION_TapByCV {
uiTypes, _ := builtin.ConvertToStringSlice(ma.Params)
ma.ActionOptions.ScreenShotWithUITypes = append(ma.ActionOptions.ScreenShotWithUITypes, uiTypes...)
ma.ActionOptions.ScreenShotWithUpload = true
}
// set default max_retry_times to 10 for swipe_to_tap_texts
if ma.Method == uixt.ACTION_SwipeToTapTexts && actionOptions.MaxRetryTimes == 0 {
ma.ActionOptions.MaxRetryTimes = 10
}
// set default max_retry_times to 10 for swipe_to_tap_text
if ma.Method == uixt.ACTION_SwipeToTapText && actionOptions.MaxRetryTimes == 0 {
ma.ActionOptions.MaxRetryTimes = 10
}
if ma.Method == uixt.ACTION_Swipe {
ma.ActionOptions.Direction = ma.Params
}
mobileUI.Actions[i] = ma
}
}
// convertJmespathExpr deals with limited jmespath expression conversion
func convertJmespathExpr(checkExpr string) string {
if strings.Contains(checkExpr, textExtractorSubRegexp) {
return checkExpr
}
checkItems := strings.Split(checkExpr, ".")
for i, checkItem := range checkItems {
checkItem = strings.Trim(checkItem, "\"")
lowerItem := strings.ToLower(checkItem)
if strings.HasPrefix(lowerItem, "content-") || lowerItem == "user-agent" {
checkItems[i] = fmt.Sprintf("\"%s\"", checkItem)
}
}
return strings.Join(checkItems, ".")
}

View File

@@ -1,275 +0,0 @@
package hrp
import (
"reflect"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/pkg/uixt"
)
type IConfig interface {
Get() *TConfig
}
// NewConfig returns a new constructed testcase config with specified testcase name.
func NewConfig(name string) *TConfig {
return &TConfig{
Name: name,
Environs: make(map[string]string),
Variables: make(map[string]interface{}),
}
}
// define struct for testcase config
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"` // deprecated in v4.1, moved to env
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // public request headers
Environs map[string]string `json:"environs,omitempty" yaml:"environs,omitempty"` // environment variables
Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` // global variables
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"`
WebSocketSetting *WebSocketConfig `json:"websocket,omitempty" yaml:"websocket,omitempty"`
IOS []*uixt.IOSDevice `json:"ios,omitempty" yaml:"ios,omitempty"`
Android []*uixt.AndroidDevice `json:"android,omitempty" yaml:"android,omitempty"`
Harmony []*uixt.HarmonyDevice `json:"harmony,omitempty" yaml:"harmony,omitempty"`
RequestTimeout float32 `json:"request_timeout,omitempty" yaml:"request_timeout,omitempty"` // request timeout in seconds
CaseTimeout float32 `json:"case_timeout,omitempty" yaml:"case_timeout,omitempty"` // testcase timeout in seconds
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
PluginSetting *PluginConfig `json:"plugin,omitempty" yaml:"plugin,omitempty"` // plugin config
IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"`
}
func (c *TConfig) Get() *TConfig {
return c
}
// 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
}
// SetRequestTimeout sets request timeout in seconds.
func (c *TConfig) SetRequestTimeout(seconds float32) *TConfig {
c.RequestTimeout = seconds
return c
}
// SetCaseTimeout sets testcase timeout in seconds.
func (c *TConfig) SetCaseTimeout(seconds float32) *TConfig {
c.CaseTimeout = seconds
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
}
func (c *TConfig) SetWebSocket(times, interval, timeout, size int64) *TConfig {
c.WebSocketSetting = &WebSocketConfig{
ReconnectionTimes: times,
ReconnectionInterval: interval,
MaxMessageSize: size,
}
return c
}
func (c *TConfig) SetIOS(options ...uixt.IOSDeviceOption) *TConfig {
wdaOptions := &uixt.IOSDevice{}
for _, option := range options {
option(wdaOptions)
}
// each device can have its own settings
if wdaOptions.UDID != "" {
c.IOS = append(c.IOS, wdaOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.IOS) == 0 {
c.IOS = append(c.IOS, wdaOptions)
} else {
c.IOS[0] = wdaOptions
}
return c
}
func (c *TConfig) SetHarmony(options ...uixt.HarmonyDeviceOption) *TConfig {
harmonyOptions := &uixt.HarmonyDevice{}
for _, option := range options {
option(harmonyOptions)
}
// each device can have its own settings
if harmonyOptions.ConnectKey != "" {
c.Harmony = append(c.Harmony, harmonyOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.Harmony) == 0 {
c.Harmony = append(c.Harmony, harmonyOptions)
} else {
c.Harmony[0] = harmonyOptions
}
return c
}
func (c *TConfig) SetAndroid(options ...uixt.AndroidDeviceOption) *TConfig {
uiaOptions := &uixt.AndroidDevice{}
for _, option := range options {
option(uiaOptions)
}
// each device can have its own settings
if uiaOptions.SerialNumber != "" {
c.Android = append(c.Android, uiaOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.Android) == 0 {
c.Android = append(c.Android, uiaOptions)
} else {
c.Android[0] = uiaOptions
}
return c
}
// EnablePlugin enables plugin for current testcase.
// default to disable plugin
func (c *TConfig) EnablePlugin() *TConfig {
c.PluginSetting = &PluginConfig{}
return c
}
func (c *TConfig) DisableAutoPopupHandler() *TConfig {
c.IgnorePopup = true
return c
}
type ThinkTimeConfig struct {
Strategy thinkTimeStrategy `json:"strategy,omitempty" yaml:"strategy,omitempty"` // default、random、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 PluginConfig struct {
Path string
Type string // bin、so、py
Content []byte
}

View File

@@ -1,229 +0,0 @@
package builtin
import (
"fmt"
"reflect"
"strings"
"github.com/stretchr/testify/assert"
)
var Assertions = map[string]func(t assert.TestingT, actual interface{}, expected interface{}, msgAndArgs ...interface{}) bool{
"eq": EqualValues,
"equals": EqualValues,
"equal": EqualValues,
"lt": assert.Less,
"less_than": assert.Less,
"le": assert.LessOrEqual,
"less_or_equals": assert.LessOrEqual,
"gt": assert.Greater,
"greater_than": assert.Greater,
"ge": assert.GreaterOrEqual,
"greater_or_equals": assert.GreaterOrEqual,
"ne": NotEqual,
"not_equal": NotEqual,
"contains": assert.Contains,
"type_match": assert.IsType,
// custom assertions
"startswith": StartsWith,
"endswith": EndsWith,
"len_eq": EqualLength,
"length_equals": EqualLength,
"length_equal": EqualLength,
"len_lt": LessThanLength,
"count_lt": LessThanLength,
"length_less_than": LessThanLength,
"len_le": LessOrEqualsLength,
"count_le": LessOrEqualsLength,
"length_less_or_equals": LessOrEqualsLength,
"len_gt": GreaterThanLength,
"count_gt": GreaterThanLength,
"length_greater_than": GreaterThanLength,
"len_ge": GreaterOrEqualsLength,
"count_ge": GreaterOrEqualsLength,
"length_greater_or_equals": GreaterOrEqualsLength,
"contained_by": ContainedBy,
"str_eq": StringEqual,
"string_equals": StringEqual,
"equal_fold": EqualFold,
"regex_match": RegexMatch,
}
func EqualValues(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.EqualValues(t, expected, actual, msgAndArgs)
}
func NotEqual(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.NotEqual(t, expected, actual, msgAndArgs)
}
// StartsWith check if string starts with substring
func StartsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) {
return false
}
if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.HasPrefix(actualString, expectedString), msgAndArgs...)
}
// EndsWith check if string ends with substring
func EndsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) {
return false
}
if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.HasSuffix(actualString, expectedString), msgAndArgs...)
}
func EqualLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expect int type, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("actual value %v(%T) can't get length", actual, actual), msgAndArgs...)
}
if l != length {
return assert.Fail(t, fmt.Sprintf("%v length == %d, expect == %d", actual, l, length), msgAndArgs...)
}
return true
}
func GreaterThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expect int type, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("actual value %v(%T) can't get length", actual, actual), msgAndArgs...)
}
if l <= length {
return assert.Fail(t, fmt.Sprintf("%v length == %d, expect > %d", actual, l, length), msgAndArgs...)
}
return true
}
func GreaterOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expect int type, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("actual value %v(%T) can't get length", actual, actual), msgAndArgs...)
}
if l < length {
return assert.Fail(t, fmt.Sprintf("%v length == %d, expect >= %d", actual, l, length), msgAndArgs...)
}
return true
}
func LessThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expect int type, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("actual value %v(%T) can't get length", actual, actual), msgAndArgs...)
}
if l >= length {
return assert.Fail(t, fmt.Sprintf("%v length == %d, expect < %d", actual, l, length), msgAndArgs...)
}
return true
}
func LessOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
length, err := convertInt(expected)
if err != nil {
return assert.Fail(t, fmt.Sprintf("expect int type, got %#v", expected), msgAndArgs...)
}
ok, l := getLen(actual)
if !ok {
return assert.Fail(t, fmt.Sprintf("actual value %v(%T) can't get length", actual, actual), msgAndArgs...)
}
if l > length {
return assert.Fail(t, fmt.Sprintf("%v length == %d, expect <= %d", actual, l, length), msgAndArgs...)
}
return true
}
// ContainedBy assert whether actual element contains expected element
func ContainedBy(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.Contains(t, expected, actual, msgAndArgs)
}
func StringEqual(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
a := fmt.Sprintf("%v", actual)
e := fmt.Sprintf("%v", expected)
return assert.True(t, a == e, msgAndArgs)
}
func EqualFold(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
if !assert.IsType(t, "string", actual, msgAndArgs) {
return false
}
if !assert.IsType(t, "string", expected, msgAndArgs) {
return false
}
actualString := actual.(string)
expectedString := expected.(string)
return assert.True(t, strings.EqualFold(actualString, expectedString), msgAndArgs)
}
func RegexMatch(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool {
return assert.Regexp(t, expected, actual, msgAndArgs)
}
func convertInt(value interface{}) (int, error) {
switch v := value.(type) {
case int:
return v, nil
case int8:
return int(v), nil
case int16:
return int(v), nil
case int32:
return int(v), nil
case int64:
return int(v), nil
case uint:
return int(v), nil
case uint8:
return int(v), nil
case uint16:
return int(v), nil
case uint32:
return int(v), nil
case uint64:
return int(v), nil
case float32:
return int(v), nil
case float64:
return int(v), nil
default:
return 0, fmt.Errorf("unsupported int convertion for %v(%T)", v, v)
}
}
// getLen try to get length of object.
// return (false, 0) if impossible.
func getLen(x interface{}) (ok bool, length int) {
v := reflect.ValueOf(x)
defer func() {
if e := recover(); e != nil {
ok = false
}
}()
return true, v.Len()
}

View File

@@ -1,212 +0,0 @@
package builtin
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStartsWith(t *testing.T) {
testData := []struct {
raw string
expected string
}{
{"", ""},
{"a", "a"},
{"abc", "a"},
{"abc", "ab"},
}
for _, data := range testData {
if !assert.True(t, StartsWith(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestEndsWith(t *testing.T) {
testData := []struct {
raw string
expected string
}{
{"", ""},
{"a", "a"},
{"abc", "c"},
{"abc", "bc"},
}
for _, data := range testData {
if !assert.True(t, EndsWith(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestEqualLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 0},
{[]string{}, 0},
{map[string]interface{}{}, 0},
{"a", 1},
{[]string{"a"}, 1},
{map[string]interface{}{"a": 123}, 1},
}
for _, data := range testData {
if !assert.True(t, EqualLength(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestLessThanLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 1},
{[]string{}, 1},
{map[string]interface{}{}, 1},
{"a", 2},
{[]string{"a"}, 2},
{map[string]interface{}{"a": 123}, 2},
}
for _, data := range testData {
if !assert.True(t, LessThanLength(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestLessOrEqualsLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"", 1},
{[]string{}, 1},
{map[string]interface{}{"A": 111}, 1},
{"a", 1},
{[]string{"a"}, 2},
{map[string]interface{}{"a": 123}, 2},
}
for _, data := range testData {
if !assert.True(t, LessOrEqualsLength(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestGreaterThanLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"abcd", 3},
{[]string{"a", "b", "c"}, 2},
{map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2},
}
for _, data := range testData {
if !assert.True(t, GreaterThanLength(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestGreaterOrEqualsLength(t *testing.T) {
testData := []struct {
raw interface{}
expected int
}{
{"abcd", 3},
{[]string{"w"}, 1},
{map[string]interface{}{"A": 111}, 1},
{"a", 1},
{[]string{"a", "b", "c"}, 2},
{map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2},
}
for _, data := range testData {
if !assert.True(t, GreaterOrEqualsLength(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestContainedBy(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"abcd", "abcdefg"},
{"a", []string{"a", "b", "c"}},
{"A", map[string]interface{}{"A": 111, "B": 222}},
}
for _, data := range testData {
if !assert.True(t, ContainedBy(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestStringEqual(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"abcd", "abcd"},
{"0", 0},
{"123", 123},
// {"123.0", 123.0}, // FIXME
{"12.3", 12.3},
{"-12.3", -12.3},
{"-123", -123},
}
for _, data := range testData {
if !assert.True(t, StringEqual(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestEqualFold(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"abcd", "abcd"},
{"abcd", "ABCD"},
{"ABcd", "abCD"},
}
for _, data := range testData {
if !assert.True(t, EqualFold(t, data.raw, data.expected)) {
t.Fatal()
}
}
}
func TestRegexMatch(t *testing.T) {
testData := []struct {
raw interface{}
expected interface{}
}{
{"it's starting...", regexp.MustCompile("start")},
{"it's not starting", "starting$"},
}
for _, data := range testData {
if !assert.True(t, RegexMatch(t, data.raw, data.expected)) {
t.Fatal()
}
}
}

View File

@@ -1,238 +0,0 @@
package builtin
import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"math"
"math/rand"
"mime"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
var Functions = map[string]interface{}{
"get_timestamp": getTimestamp, // call without arguments
"sleep": sleep, // call with one argument
"gen_random_string": genRandomString, // call with one argument
"random_int": rand.Intn, // call with one argument
"random_range": random_range, // call with two arguments
"max": math.Max, // call with two arguments
"md5": MD5, // call with one argument
"parameterize": loadFromCSV,
"P": loadFromCSV,
"split_by_comma": splitByComma, // call with one argument
"environ": os.Getenv,
"ENV": os.Getenv,
"load_ws_message": loadMessage,
"multipart_encoder": multipartEncoder,
"multipart_content_type": multipartContentType,
}
// upload file path must starts with @, like @\"PATH\" or @PATH
var regexUploadFilePath = regexp.MustCompile(`^@(.*)`)
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}
func init() {
rand.Seed(time.Now().UnixNano())
}
func random_range(a, b float64) float64 {
return a + rand.Float64()*(b-a)
}
func getTimestamp() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func sleep(nSecs int) {
time.Sleep(time.Duration(nSecs) * time.Second)
}
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
func genRandomString(n int) string {
lettersLen := len(letters)
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(lettersLen)]
}
return string(b)
}
func MD5(str string) string {
hasher := md5.New()
hasher.Write([]byte(str))
return hex.EncodeToString(hasher.Sum(nil))
}
type TFormDataWriter struct {
Writer *multipart.Writer
Payload *bytes.Buffer
}
func (w *TFormDataWriter) writeCustomText(formKey, formValue, formType, formFileName string) error {
if w.Writer == nil {
return errors.New("form-data writer not initialized")
}
h := make(textproto.MIMEHeader)
// text doesn't have Content-Type by default
if formType != "" {
h.Set("Content-Type", formType)
}
// text doesn't have filename in Content-Disposition by default
if formFileName == "" {
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"`, escapeQuotes(formKey)))
} else {
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(formKey), escapeQuotes(formFileName)))
}
part, err := w.Writer.CreatePart(h)
if err != nil {
return err
}
_, err = part.Write([]byte(formValue))
return err
}
func (w *TFormDataWriter) writeCustomFile(formKey, formValue, formType, formFileName string) error {
if w.Writer == nil {
return errors.New("form-data writer not initialized")
}
fPath, err := filepath.Abs(formValue)
if err != nil {
return err
}
file, err := os.ReadFile(fPath)
if err != nil {
return err
}
if formType == "" {
formType = inferFormType(formValue)
}
if formFileName == "" {
formFileName = filepath.Base(formValue)
}
h := make(textproto.MIMEHeader)
h.Set("Content-Type", formType)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(formKey), escapeQuotes(formFileName)))
part, err := w.Writer.CreatePart(h)
if err != nil {
return err
}
_, err = part.Write(file)
return err
}
func inferFormType(formValue string) string {
extName := filepath.Ext(formValue)
formType := mime.TypeByExtension(extName)
if formType == "" {
// file without extension name
return "application/octet-stream"
}
if strings.HasPrefix(formType, "text") {
// text/... types have the charset parameter set to "utf-8" by default.
return strings.TrimSuffix(formType, "; charset=utf-8")
}
return formType
}
func multipartEncoder(formMap map[string]interface{}) (*TFormDataWriter, error) {
payload := &bytes.Buffer{}
writer := multipart.NewWriter(payload)
tFormWriter := &TFormDataWriter{
Writer: writer,
Payload: payload,
}
// e.g. formMap: {"file": "@\"$upload_file\";type=text/foo"}
for formKey, formData := range formMap {
formDataString := fmt.Sprintf("%v", formData)
formItems := strings.Split(formDataString, ";")
var isFilePath bool
var formValue, formType, formFileName string
for _, formItem := range formItems {
if formItem == "" {
continue
}
equalSignIndex := strings.Index(formItem, "=")
// parse form value, e.g. @\"$upload_file\"
if equalSignIndex == -1 {
matchRes := regexUploadFilePath.FindStringSubmatch(formItem)
if len(matchRes) > 1 {
// formItem started with @, regarded as File path
isFilePath = true
formValue = strings.Trim(matchRes[1], "\"")
} else {
// formItem is not a valid File path, regarded as Text instead
formValue = strings.TrimSuffix(strings.TrimPrefix(formItem, "\""), "\"")
}
continue
}
// parse form option, e.g. type=text/plain
leftPart := strings.TrimSpace(formItem[:equalSignIndex])
var rightPart string
if equalSignIndex < len(formItem)-1 {
rightPart = strings.TrimSpace(formItem[equalSignIndex+1:])
}
if (strings.ToLower(leftPart) != "type" && strings.ToLower(leftPart) != "filename") || rightPart == "" {
formOption := fmt.Sprintf("%s=%s", leftPart, rightPart)
log.Warn().Msgf("invalid form option: %v, ignore", formOption)
continue
}
if strings.ToLower(leftPart) == "type" {
formType = rightPart
}
if strings.ToLower(leftPart) == "filename" {
formFileName = rightPart
}
}
if isFilePath {
if err := tFormWriter.writeCustomFile(formKey, formValue, formType, formFileName); err != nil {
log.Error().Err(err).Msgf("failed to write file: %v=@\"%v\", exit", formKey, formValue)
return nil, err
}
continue
}
if err := tFormWriter.writeCustomText(formKey, formValue, formType, formFileName); err != nil {
log.Error().Err(err).Msgf("failed to write text: %v=%v, ignore", formKey, formValue)
return nil, err
}
}
if err := writer.Close(); err != nil {
log.Error().Err(err).Msg("failed to close form-data writer")
}
return tFormWriter, nil
}
func multipartContentType(w *TFormDataWriter) string {
if w.Writer == nil {
return ""
}
return w.Writer.FormDataContentType()
}
func splitByComma(s string) []string {
return strings.Split(s, ",")
}

View File

@@ -1,584 +0,0 @@
package builtin
import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/csv"
builtinJSON "encoding/json"
"fmt"
"io"
"math"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/BurntSushi/locker"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/json"
)
func Dump2JSON(data interface{}, path string) error {
path, err := filepath.Abs(path)
if err != nil {
log.Error().Err(err).Msg("convert absolute path failed")
return err
}
log.Info().Str("path", path).Msg("dump data to json")
// init json encoder
buffer := new(bytes.Buffer)
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
err = encoder.Encode(data)
if err != nil {
return err
}
err = os.WriteFile(path, buffer.Bytes(), 0o644)
if err != nil {
log.Error().Err(err).Msg("dump json path failed")
return err
}
return nil
}
func Dump2YAML(data interface{}, path string) error {
path, err := filepath.Abs(path)
if err != nil {
log.Error().Err(err).Msg("convert absolute path failed")
return err
}
log.Info().Str("path", path).Msg("dump data to yaml")
// init yaml encoder
buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(4)
// encode
err = encoder.Encode(data)
if err != nil {
return err
}
err = os.WriteFile(path, buffer.Bytes(), 0o644)
if err != nil {
log.Error().Err(err).Msg("dump yaml path failed")
return err
}
return nil
}
func FormatResponse(raw interface{}) interface{} {
formattedResponse := make(map[string]interface{})
for key, value := range raw.(map[string]interface{}) {
// convert value to json
if key == "body" {
b, _ := json.MarshalIndent(&value, "", " ")
value = string(b)
}
formattedResponse[key] = value
}
return formattedResponse
}
func CreateFolder(folderPath string) error {
log.Info().Str("path", folderPath).Msg("create folder")
err := os.MkdirAll(folderPath, os.ModePerm)
if err != nil {
log.Error().Err(err).Msg("create folder failed")
return err
}
return nil
}
func CreateFile(filePath string, data string) error {
log.Info().Str("path", filePath).Msg("create file")
err := os.WriteFile(filePath, []byte(data), 0o644)
if err != nil {
log.Error().Err(err).Msg("create file failed")
return err
}
return nil
}
// IsPathExists returns true if path exists, whether path is file or dir
func IsPathExists(path string) bool {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
return true
}
// IsFilePathExists returns true if path exists and path is file
func IsFilePathExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
// path not exists
return false
}
// path exists
if info.IsDir() {
// path is dir, not file
return false
}
return true
}
// IsFolderPathExists returns true if path exists and path is folder
func IsFolderPathExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
// path not exists
return false
}
// path exists and is dir
return info.IsDir()
}
func EnsureFolderExists(folderPath string) error {
if !IsPathExists(folderPath) {
err := CreateFolder(folderPath)
return err
} else if IsFilePathExists(folderPath) {
return fmt.Errorf("path %v should be directory", folderPath)
}
return nil
}
func Contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func GetRandomNumber(min, max int) int {
if min > max {
return 0
}
r := rand.Intn(max - min + 1)
return min + r
}
func Interface2Float64(i interface{}) (float64, error) {
switch v := i.(type) {
case int:
return float64(v), nil
case int32:
return float64(v), nil
case int64:
return float64(v), nil
case float32:
return float64(v), nil
case float64:
return v, nil
case string:
floatVar, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, err
}
return floatVar, err
}
// json.Number
value, ok := i.(builtinJSON.Number)
if ok {
return value.Float64()
}
return 0, errors.New("failed to convert interface to float64")
}
func TypeNormalization(raw interface{}) interface{} {
switch v := raw.(type) {
case int, int8, int16, int32, int64:
return reflect.ValueOf(v).Int()
case uint, uint8, uint16, uint32, uint64:
return reflect.ValueOf(v).Uint()
case float32, float64:
return reflect.ValueOf(v).Float()
default:
return raw
}
}
func InterfaceType(raw interface{}) string {
if raw == nil {
return ""
}
return reflect.TypeOf(raw).String()
}
func loadFromCSV(path string) []map[string]interface{} {
log.Info().Str("path", path).Msg("load csv file")
file, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("read csv file failed")
os.Exit(code.GetErrorCode(err))
}
r := csv.NewReader(strings.NewReader(string(file)))
content, err := r.ReadAll()
if err != nil {
log.Error().Err(err).Msg("parse csv file failed")
os.Exit(code.GetErrorCode(err))
}
firstLine := content[0] // parameter names
var result []map[string]interface{}
for i := 1; i < len(content); i++ {
row := make(map[string]interface{})
for j := 0; j < len(content[i]); j++ {
row[firstLine[j]] = content[i][j]
}
result = append(result, row)
}
return result
}
func loadMessage(path string) []byte {
log.Info().Str("path", path).Msg("load message file")
file, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("read message file failed")
os.Exit(code.GetErrorCode(err))
}
return file
}
func GetFileNameWithoutExtension(path string) string {
base := filepath.Base(path)
ext := filepath.Ext(base)
return base[0 : len(base)-len(ext)]
}
func sha256HMAC(key []byte, data []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(data)
return []byte(fmt.Sprintf("%x", mac.Sum(nil)))
}
// ver: auth-v1 or auth-v2
func Sign(ver string, ak string, sk string, body []byte) string {
expiration := 1800
signKeyInfo := fmt.Sprintf("%s/%s/%d/%d", ver, ak, time.Now().Unix(), expiration)
signKey := sha256HMAC([]byte(sk), []byte(signKeyInfo))
signResult := sha256HMAC(signKey, body)
return fmt.Sprintf("%v/%v", signKeyInfo, string(signResult))
}
func GenNameWithTimestamp(tmpl string) string {
if !strings.Contains(tmpl, "%d") {
tmpl = tmpl + "_%d"
}
return fmt.Sprintf(tmpl, time.Now().Unix())
}
func IsZeroFloat64(f float64) bool {
threshold := 1e-9
return math.Abs(f) < threshold
}
func ConvertToFloat64(val interface{}) (float64, error) {
switch v := val.(type) {
case float64:
return v, nil
case int:
return float64(v), nil
case int64:
return float64(v), nil
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
log.Error().Err(err).Str("value", v).
Msg("convert string to float64 failed")
return 0, err
}
return f, nil
default:
log.Error().Interface("value", val).Type("type", val).
Msg("convert float64 failed")
return 0, errors.New("convert float64 error")
}
}
func ConvertToFloat64Slice(val interface{}) ([]float64, error) {
if paramsSlice, ok := val.([]float64); ok {
return paramsSlice, nil
}
paramsSlice, ok := val.([]interface{})
if !ok {
return nil, errors.New("val is not slice")
}
var err error
float64Slice := make([]float64, len(paramsSlice))
for i, v := range paramsSlice {
float64Slice[i], err = ConvertToFloat64(v)
if err != nil {
return nil, err
}
}
return float64Slice, nil
}
func ConvertToStringSlice(val interface{}) ([]string, error) {
paramsSlice, ok := val.([]interface{})
if !ok {
return nil, errors.New("val is not slice")
}
stringSlice := make([]string, len(paramsSlice))
for i, v := range paramsSlice {
stringSlice[i], ok = v.(string)
if !ok {
return nil, errors.New("val is not string slice")
}
}
return stringSlice, nil
}
func GetFreePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, errors.Wrap(err, "resolve tcp addr failed")
}
l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, errors.Wrap(err, "listen tcp addr failed")
}
defer func() {
if err = l.Close(); err != nil {
log.Error().Err(err).Msg(fmt.Sprintf("close addr %s error", l.Addr().String()))
}
}()
return l.Addr().(*net.TCPAddr).Port, nil
}
func GetCurrentDay() string {
now := time.Now()
// 格式化日期为 yyyyMMdd
formattedDate := now.Format("20060102")
return formattedDate
}
func DownloadFile(filePath string, fileUrl string) error {
log.Info().Str("filePath", filePath).Str("url", fileUrl).Msg("download file")
parsedURL, err := url.Parse(fileUrl)
if err != nil {
return err
}
out, err := os.Create(filePath)
if err != nil {
return err
}
defer out.Close()
// 创建一个新的 HTTP 请求
req, err := http.NewRequest("GET", fileUrl, nil)
if err != nil {
return err
}
// TODO: rename token
eapiToken := os.Getenv("EAPI_TOKEN")
if eapiToken != "" {
if parsedURL.Host != "gtf-eapi-cn.bytedance.com" && parsedURL.Host != "gtf-eapi-cn.bytedance.net" {
return errors.New("invalid domain: must be gtf-eapi-cn.bytedance.com")
}
// 添加自定义头部
req.Header.Add("accessKey", "ies.vedem.video")
req.Header.Add("token", eapiToken)
}
// 创建一个 HTTP 客户端并发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s, download failed", resp.Status)
}
// 将响应主体写入文件
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
}
func fileExists(filepath string) bool {
_, err := os.Stat(filepath)
if os.IsNotExist(err) {
return false // 文件不存在
}
return err == nil // 文件存在,且没有其他错误
}
func DownloadFileByUrl(fileUrl string) (filePath string, err error) {
// 使用 UUID 生成唯一文件名
cwd, err := os.Getwd()
if err != nil {
return "", err
}
hash := md5.Sum([]byte(fileUrl))
fileName := fmt.Sprintf("%x", hash)
filePath = filepath.Join(cwd, fileName)
locker.Lock(filePath)
defer locker.Unlock(filePath)
if fileExists(filePath) {
return filePath, nil
}
fmt.Printf("Downloading file to %s from URL %s\n", filePath, fileUrl)
// Create an HTTP client with default settings.
client := &http.Client{}
// Build the HTTP GET request.
req, err := http.NewRequest("GET", fileUrl, nil)
if err != nil {
return "", err
}
// Perform the request.
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Check the HTTP status code.
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download file: %s", resp.Status)
}
// Create the output file.
outFile, err := os.Create(fileName)
if err != nil {
return "", err
}
defer outFile.Close()
// Copy the response body to the file.
_, err = io.Copy(outFile, resp.Body)
if err != nil {
return "", err
}
fmt.Printf("File downloaded successfully: %s\n", fileName)
return filePath, nil
}
func RunCommand(cmdName string, args ...string) error {
cmd := exec.Command(cmdName, args...)
log.Info().Str("command", cmd.String()).Msg("exec command")
// print stderr output
var stderr bytes.Buffer
cmd.Stderr = &stderr
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
stderrStr := stderr.String()
log.Error().Err(err).Msg("failed to exec command. msg: " + stderrStr)
if stderrStr != "" {
err = errors.Wrap(err, stderrStr)
}
return err
}
stderrStr := stderr.String()
log.Error().Msg("failed to exec command. msg: " + stderrStr)
log.Info().Msg("exec command output: " + stdout.String())
return nil
}
type LineCallback func(line string) bool
// RunCommandWithCallback 运行命令并根据回调判断是否成功
func RunCommandWithCallback(cmdName string, args []string, callback LineCallback) error {
cmd := exec.Command(cmdName, args...)
log.Info().Str("command", cmd.String()).Msg("exec command")
// 使用管道获取标准输出
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
log.Error().Err(err).Msg("failed to get stdout pipe")
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
log.Error().Err(err).Msg("failed to start command")
return err
}
// 创建一个用于标识成功的通道
done := make(chan struct{})
defer close(done)
// 逐行读取 stdout
go func() {
stdoutScanner := bufio.NewScanner(stdoutPipe)
for stdoutScanner.Scan() {
line := stdoutScanner.Text()
log.Info().Msg("stdout: " + line)
if callback(line) {
done <- struct{}{}
return
}
}
}()
// 等待命令执行完成
err = cmd.Wait()
if err != nil {
log.Error().Msg("failed to exec command. msg: " + stderr.String())
return err
}
// 设置一个1秒的超时上下文
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
select {
case <-done:
return nil
case <-ctx.Done():
// 超时,判断失败
log.Error().Msg("failed to exec command. msg: " + stderr.String())
err = errors.New("command execution failed: callback failed while exec command")
log.Error().Err(err).Msg("failed to find keyword in time")
return err
}
}

View File

@@ -1,38 +0,0 @@
package config
import (
"os"
"path/filepath"
"time"
)
const (
ResultsDirName = "results"
ScreenshotsDirName = "screenshots"
ActionLogDireName = "action_log"
)
var (
RootDir string
ResultsDir string
ResultsPath string
ScreenShotsPath string
StartTime = time.Now()
StartTimeStr = StartTime.Format("20060102150405")
ActionLogFilePath string
DeviceActionLogFilePath string
)
func init() {
var err error
RootDir, err = os.Getwd()
if err != nil {
panic(err)
}
ResultsDir = filepath.Join(ResultsDirName, StartTimeStr)
ResultsPath = filepath.Join(RootDir, ResultsDir)
ScreenShotsPath = filepath.Join(ResultsPath, ScreenshotsDirName)
ActionLogFilePath = filepath.Join(ResultsDir, ActionLogDireName)
DeviceActionLogFilePath = "/sdcard/Android/data/io.appium.uiautomator2.server/files/hodor"
}

View File

@@ -1,17 +0,0 @@
package json
import (
jsoniter "github.com/json-iterator/go"
)
// replace with third-party json library to improve performance
var json = jsoniter.ConfigCompatibleWithStandardLibrary
var (
Marshal = json.Marshal
MarshalIndent = json.MarshalIndent
Unmarshal = json.Unmarshal
NewDecoder = json.NewDecoder
NewEncoder = json.NewEncoder
Get = json.Get
)

View File

@@ -1,10 +0,0 @@
package pytest
import (
"github.com/httprunner/funplugin/myexec"
)
func RunPytest(args []string) error {
args = append([]string{"run"}, args...)
return myexec.ExecPython3Command("httprunner", args...)
}

View File

@@ -1,34 +0,0 @@
package scaffold
import (
"path/filepath"
"testing"
)
func TestGenDemoExamples(t *testing.T) {
dir := "../../../examples/demo-with-go-plugin"
err := CreateScaffold(dir, Go, "", true)
if err != nil {
t.Fatal(err)
}
dir = "../../../examples/demo-with-py-plugin"
venv := filepath.Join(dir, ".venv")
_ = CreateScaffold(dir, Py, venv, true)
// FIXME
// if err != nil {
// t.Fatal(err)
// }
dir = "../../../examples/demo-without-plugin"
err = CreateScaffold(dir, Ignore, "", true)
if err != nil {
t.Fatal(err)
}
dir = "../../../examples/demo-empty-project"
err = CreateScaffold(dir, Empty, "", true)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -1,220 +0,0 @@
package scaffold
import (
"embed"
"fmt"
"os"
"path/filepath"
"time"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
"github.com/httprunner/httprunner/v5/hrp/internal/config"
"github.com/httprunner/httprunner/v5/hrp/internal/sdk"
"github.com/httprunner/httprunner/v5/hrp/internal/version"
)
type PluginType string
const (
Empty PluginType = "empty"
Ignore PluginType = "ignore"
Py PluginType = "py"
Go PluginType = "go"
)
type ProjectInfo struct {
ProjectName string `json:"project_name,omitempty" yaml:"project_name,omitempty"`
CreateTime time.Time `json:"create_time,omitempty" yaml:"create_time,omitempty"`
Version string `json:"hrp_version,omitempty" yaml:"hrp_version,omitempty"`
}
//go:embed templates/*
var templatesDir embed.FS
// CopyFile copies a file from templates dir to scaffold project
func CopyFile(templateFile, targetFile string) error {
log.Info().Str("path", targetFile).Msg("create file")
content, err := templatesDir.ReadFile(templateFile)
if err != nil {
return errors.Wrap(err, "template file not found")
}
err = os.WriteFile(targetFile, content, 0o644)
if err != nil {
log.Error().Err(err).Msg("create file failed")
return err
}
return nil
}
func CreateScaffold(projectName string, pluginType PluginType, venv string, force bool) error {
// report GA event
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_startproject", map[string]interface{}{
"pluginType": string(pluginType),
"force": force,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
log.Info().
Str("projectName", projectName).
Str("pluginType", string(pluginType)).
Bool("force", force).
Msg("create new scaffold project")
// check if projectName exists
if _, err := os.Stat(projectName); err == nil {
if !force {
log.Warn().Str("projectName", projectName).
Msg("project name already exists, please specify a new one.")
return fmt.Errorf("project name already exists")
}
log.Warn().Str("projectName", projectName).
Msg("project name already exists, remove first !!!")
os.RemoveAll(projectName)
}
// create project folders
if err := builtin.CreateFolder(projectName); err != nil {
return err
}
if err := builtin.CreateFolder(filepath.Join(projectName, "har")); err != nil {
return err
}
if err := builtin.CreateFile(filepath.Join(projectName, "har", ".keep"), ""); err != nil {
return err
}
if err := builtin.CreateFolder(filepath.Join(projectName, "testcases")); err != nil {
return err
}
if err := builtin.CreateFolder(filepath.Join(projectName, config.ResultsDirName)); err != nil {
return err
}
if err := builtin.CreateFile(filepath.Join(projectName, config.ResultsDirName, ".keep"), ""); err != nil {
return err
}
projectInfo := &ProjectInfo{
ProjectName: filepath.Base(projectName),
CreateTime: time.Now(),
Version: version.VERSION,
}
// dump project information to file
err := builtin.Dump2JSON(projectInfo, filepath.Join(projectName, "proj.json"))
if err != nil {
return err
}
// create .gitignore
err = CopyFile("templates/gitignore", filepath.Join(projectName, ".gitignore"))
if err != nil {
return err
}
// create .env
err = CopyFile("templates/env", filepath.Join(projectName, ".env"))
if err != nil {
return err
}
// create project testcases
if pluginType == Empty {
// create empty project
err := CopyFile("templates/testcases/demo_empty_request.json",
filepath.Join(projectName, "testcases", "requests.json"))
if err != nil {
return err
}
return nil
} else if pluginType == Ignore {
// create project without funplugin
err := CopyFile("templates/testcases/demo_without_funplugin.json",
filepath.Join(projectName, "testcases", "requests.json"))
if err != nil {
return err
}
log.Info().Msg("skip creating function plugin")
return nil
}
// create project with funplugin
err = CopyFile("templates/testcases/demo_with_funplugin.json",
filepath.Join(projectName, "testcases", "demo.json"))
if err != nil {
return err
}
err = CopyFile("templates/testcases/demo_requests.json",
filepath.Join(projectName, "testcases", "requests.json"))
if err != nil {
return err
}
err = CopyFile("templates/testcases/demo_requests.yml",
filepath.Join(projectName, "testcases", "requests.yml"))
if err != nil {
return err
}
err = CopyFile("templates/testcases/demo_ref_testcase.yml",
filepath.Join(projectName, "testcases", "ref_testcase.yml"))
if err != nil {
return err
}
// create debugtalk function plugin
switch pluginType {
case Py:
return createPythonPlugin(projectName, venv)
case Go:
return createGoPlugin(projectName)
}
return nil
}
func createGoPlugin(projectName string) error {
log.Info().Msg("start to create hashicorp go plugin")
// check go sdk
if err := myexec.RunCommand("go", "version"); err != nil {
return errors.Wrap(err, "go sdk not installed")
}
// create debugtalk.go
pluginDir := filepath.Join(projectName, "plugin")
if err := builtin.CreateFolder(pluginDir); err != nil {
return err
}
err := CopyFile("templates/plugin/debugtalk.go",
filepath.Join(projectName, "plugin", hrp.PluginGoSourceFile))
if err != nil {
return errors.Wrap(err, "copy debugtalk.go failed")
}
return nil
}
func createPythonPlugin(projectName, venv string) error {
log.Info().Msg("start to create hashicorp python plugin")
// create debugtalk.py
pluginFile := filepath.Join(projectName, hrp.PluginPySourceFile)
err := CopyFile("templates/plugin/debugtalk.py", pluginFile)
if err != nil {
return errors.Wrap(err, "copy file failed")
}
packages := []string{"funppy", "httprunner"}
_, err = myexec.EnsurePython3Venv(venv, packages...)
if err != nil {
return errors.Wrap(code.InvalidPython3Venv, err.Error())
}
return nil
}

View File

@@ -1,34 +0,0 @@
{
"name": "",
"request": {
"method": "GET",
"url": "/get",
"params": {
"foo1": "bar1",
"foo2": "bar2"
},
"headers": {
"Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723"
}
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "assert response status code"
},
{
"check": "headers.\"Content-Type\"",
"assert": "equals",
"expect": "application/json; charset=utf-8",
"msg": "assert response header Content-Type"
},
{
"check": "body.url",
"assert": "equals",
"expect": "https://postman-echo.com/get?foo1=bar1&foo2=bar2",
"msg": "assert response body url"
}
]
}

View File

@@ -1,22 +0,0 @@
name: ""
request:
method: GET
url: /get
params:
foo1: bar1
foo2: bar2
headers:
Postman-Token: ea19464c-ddd4-4724-abe9-5e2b254c2723
validate:
- check: status_code
assert: equals
expect: 200
msg: assert response status code
- check: headers."Content-Type"
assert: equals
expect: application/json; charset=utf-8
msg: assert response header Content-Type
- check: body.url
assert: equals
expect: https://postman-echo.com/get?foo1=bar1&foo2=bar2
msg: assert response body url

View File

@@ -1,45 +0,0 @@
{
"name": "",
"request": {
"method": "POST",
"url": "/post",
"headers": {
"Content-Length": "58",
"Content-Type": "text/plain",
"Postman-Token": "$session_token"
},
"body": "This is expected to be sent back as part of response body."
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "assert response status code"
},
{
"check": "headers.\"Content-Type\"",
"assert": "equals",
"expect": "application/json; charset=utf-8",
"msg": "assert response header Content-Type"
},
{
"check": "body.data",
"assert": "equals",
"expect": "This is expected to be sent back as part of response body.",
"msg": "assert response body data"
},
{
"check": "body.json",
"assert": "equals",
"expect": null,
"msg": "assert response body json"
},
{
"check": "body.url",
"assert": "equals",
"expect": "https://postman-echo.com/post/",
"msg": "assert response body url"
}
]
}

View File

@@ -1,30 +0,0 @@
name: ""
request:
method: POST
url: /post
headers:
Content-Length: "58"
Content-Type: text/plain
Postman-Token: $session_token
body: This is expected to be sent back as part of response body.
validate:
- check: status_code
assert: equals
expect: 200
msg: assert response status code
- check: headers."Content-Type"
assert: equals
expect: application/json; charset=utf-8
msg: assert response header Content-Type
- check: body.data
assert: equals
expect: This is expected to be sent back as part of response body.
msg: assert response body data
- check: body.json
assert: equals
expect: null
msg: assert response body json
- check: body.url
assert: equals
expect: https://postman-echo.com/post/
msg: assert response body url

View File

@@ -1,45 +0,0 @@
{
"name": "",
"request": {
"method": "PUT",
"url": "/put",
"headers": {
"Content-Length": "58",
"Content-Type": "text/plain",
"Postman-Token": "5d357b2b-0f10-4ded-bc9a-299ebef7a2d5"
},
"body": "This is expected to be sent back as part of response body."
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "assert response status code"
},
{
"check": "headers.\"Content-Type\"",
"assert": "equals",
"expect": "application/json; charset=utf-8",
"msg": "assert response header Content-Type"
},
{
"check": "body.data",
"assert": "equals",
"expect": "This is expected to be sent back as part of response body.",
"msg": "assert response body data"
},
{
"check": "body.json",
"assert": "equals",
"expect": null,
"msg": "assert response body json"
},
{
"check": "body.url",
"assert": "equals",
"expect": "https://postman-echo.com/put/",
"msg": "assert response body url"
}
]
}

View File

@@ -1,30 +0,0 @@
name: ""
request:
method: PUT
url: /put
headers:
Content-Length: "58"
Content-Type: text/plain
Postman-Token: 5d357b2b-0f10-4ded-bc9a-299ebef7a2d5
body: This is expected to be sent back as part of response body.
validate:
- check: status_code
assert: equals
expect: 200
msg: assert response status code
- check: headers."Content-Type"
assert: equals
expect: application/json; charset=utf-8
msg: assert response header Content-Type
- check: body.data
assert: equals
expect: This is expected to be sent back as part of response body.
msg: assert response body data
- check: body.json
assert: equals
expect: null
msg: assert response body json
- check: body.url
assert: equals
expect: https://postman-echo.com/put/
msg: assert response body url

View File

@@ -1,3 +0,0 @@
base_url=https://postman-echo.com
USERNAME=debugtalk
PASSWORD=123456

View File

@@ -1,14 +0,0 @@
reports/
*.so
.vscode/
.idea/
.DS_Store
output/
__pycache__/
*.pyc
.python-version
logs/
# plugin
debugtalk.bin
debugtalk.so

View File

@@ -1,24 +0,0 @@
# NOTE: Generated By hrp v4.3.6, DO NOT EDIT!
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from debugtalk import *
if __name__ == "__main__":
import funppy
funppy.register("get_user_agent", get_user_agent)
funppy.register("sleep", sleep)
funppy.register("sum", sum)
funppy.register("sum_ints", sum_ints)
funppy.register("sum_two_int", sum_two_int)
funppy.register("sum_two_string", sum_two_string)
funppy.register("sum_strings", sum_strings)
funppy.register("concatenate", concatenate)
funppy.register("setup_hook_example", setup_hook_example)
funppy.register("teardown_hook_example", teardown_hook_example)
funppy.serve()

View File

@@ -1,44 +0,0 @@
package main
import (
"fmt"
)
func SumTwoInt(a, b int) int {
return a + b
}
func SumInts(args ...int) int {
var sum int
for _, arg := range args {
sum += arg
}
return sum
}
func Sum(args ...interface{}) (interface{}, error) {
var sum float64
for _, arg := range args {
switch v := arg.(type) {
case int:
sum += float64(v)
case float64:
sum += v
default:
return nil, fmt.Errorf("unexpected type: %T", arg)
}
}
return sum, nil
}
func SetupHookExample(args string) string {
return fmt.Sprintf("step name: %v, setup...", args)
}
func TeardownHookExample(args string) string {
return fmt.Sprintf("step name: %v, teardown...", args)
}
func GetUserAgent() string {
return "hrp/fungo"
}

View File

@@ -1,62 +0,0 @@
import logging
import time
from typing import List
# commented out function will be filtered
# def get_headers():
# return {"User-Agent": "hrp"}
def get_user_agent():
return "hrp/funppy"
def sleep(n_secs):
time.sleep(n_secs)
def sum(*args):
result = 0
for arg in args:
result += arg
return result
def sum_ints(*args: List[int]) -> int:
result = 0
for arg in args:
result += arg
return result
def sum_two_int(a: int, b: int) -> int:
return a + b
def sum_two_string(a: str, b: str) -> str:
return a + b
def sum_strings(*args: List[str]) -> str:
result = ""
for arg in args:
result += arg
return result
def concatenate(*args: List[str]) -> str:
result = ""
for arg in args:
result += str(arg)
return result
def setup_hook_example(name):
logging.warning("setup_hook_example")
return f"setup_hook_example: {name}"
def teardown_hook_example(name):
logging.warning("teardown_hook_example")
return f"teardown_hook_example: {name}"

View File

@@ -1,13 +0,0 @@
// NOTE: Generated By hrp {{ .Version }}, DO NOT EDIT!
package main
import (
"github.com/httprunner/funplugin/fungo"
)
func main() {
{{- range $functionName := .FunctionNames }}
fungo.Register("{{ $functionName }}", {{ $functionName }})
{{- end }}
fungo.Serve()
}

View File

@@ -1,16 +0,0 @@
# NOTE: Generated By hrp {{ .Version }}, DO NOT EDIT!
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from debugtalk import *
if __name__ == "__main__":
import funppy
{{- range $functionName := .FunctionNames }}
funppy.register("{{ $functionName }}", {{ $functionName }})
{{- end }}
funppy.serve()

View File

@@ -1,16 +0,0 @@
// NOTE: Generated By hrp v4.3.6, DO NOT EDIT!
package main
import (
"github.com/httprunner/funplugin/fungo"
)
func main() {
fungo.Register("SumTwoInt", SumTwoInt)
fungo.Register("SumInts", SumInts)
fungo.Register("Sum", Sum)
fungo.Register("SetupHookExample", SetupHookExample)
fungo.Register("TeardownHookExample", TeardownHookExample)
fungo.Register("GetUserAgent", GetUserAgent)
fungo.Serve()
}

View File

@@ -1,6 +0,0 @@
[pytest]
addopts = -s
# https://docs.pytest.org/en/latest/how-to/output.html
junit_logging = all
junit_duration_report = total
log_cli = False

View File

@@ -1,359 +0,0 @@
<head>
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TestReport</title>
<style>
body {
background-color: #f2f2f2;
color: #333;
margin: 0 auto;
width: 960px;
}
#summary {
width: 960px;
margin-bottom: 20px;
}
#summary th {
background-color: skyblue;
padding: 5px 12px;
}
#summary td {
background-color: lightblue;
text-align: center;
padding: 4px 8px;
}
.details {
width: 960px;
margin-bottom: 20px;
}
.details th {
background-color: skyblue;
padding: 5px 12px;
}
.details tr .passed {
background-color: lightgreen;
}
.details tr .failed {
background-color: red;
}
.details tr .unchecked {
background-color: gray;
}
.details td {
background-color: lightblue;
padding: 5px 12px;
}
.details .detail {
background-color: lightgrey;
font-size: smaller;
padding: 5px 10px;
line-height: 20px;
text-align: left;
}
.details .success {
background-color: greenyellow;
}
.details .error {
background-color: red;
}
.details .failure {
background-color: salmon;
}
.details .skipped {
background-color: gray;
}
.button {
font-size: 1em;
padding: 6px;
width: 4em;
text-align: center;
background-color: #06d85f;
border-radius: 20px/50px;
cursor: pointer;
transition: all 0.3s ease-out;
}
a.button {
color: gray;
text-decoration: none;
display: inline-block;
}
.button:hover {
background: #2cffbd;
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
transition: opacity 500ms;
visibility: hidden;
opacity: 0;
line-height: 25px;
}
.overlay:target {
visibility: visible;
opacity: 1;
}
.popup {
margin: 70px auto;
padding: 20px;
background: #fff;
border-radius: 10px;
width: 50%;
position: relative;
transition: all 3s ease-in-out;
}
.popup h2 {
margin-top: 0;
color: #333;
font-family: Tahoma, Arial, sans-serif;
}
.popup .close {
position: absolute;
top: 20px;
right: 30px;
transition: all 200ms;
font-size: 30px;
font-weight: bold;
text-decoration: none;
color: #333;
}
.popup .close:hover {
color: #06d85f;
}
.popup .content {
max-height: 80%;
overflow: auto;
text-align: left;
}
.popup .separator {
color: royalblue
}
@media screen and (max-width: 700px) {
.box {
width: 70%;
}
.popup {
width: 70%;
}
}
</style>
</head>
<body>
<h1>API Test Report</h1>
<h2>Summary</h2>
<table id="summary">
<tr>
<th>START AT</th>
<td colspan="4">{{.Time.StartAt}}</td>
</tr>
<tr>
<th>DURATION</th>
<td colspan="4">{{ .Time.Duration }} seconds</td>
</tr>
<tr>
<th>PLATFORM</th>
<td>HttpRunnerPlus {{ .Platform.HttprunnerVersion }}</td>
<td>{{ .Platform.GoVersion }}</td>
<td colspan="2">{{ .Platform.Platform }}</td>
</tr>
<tr>
<th>STAT</th>
<th colspan="2">TESTCASES (success/fail)</th>
<th colspan="2">TESTSTEPS (success/fail/error/skip)</th>
</tr>
<tr>
<td>total (details) =></td>
<td colspan="2">{{.Stat.TestCases.Total}} ({{.Stat.TestCases.Success}}/{{.Stat.TestCases.Fail}})</td>
<td colspan="2">{{.Stat.TestSteps.Total}} ({{.Stat.TestSteps.Successes}}/0/{{.Stat.TestSteps.Failures}}/0)</td>
</tr>
</table>
<h2>Details</h2>
{{ range $suite_index, $detail := .Details }}
<h3>{{.Name}}</h3>
<table id="suite_{{$suite_index}}" class="details">
<tr>
<td>TOTAL: {{.Stat.Total}}</td>
<td>SUCCESS: {{.Stat.Successes}}</td>
<td>FAILED: 0</td>
<td>ERROR: {{.Stat.Failures}}</td>
<td>SKIPPED: 0</td>
</tr>
<tr>
<th>Status</th>
<th colspan="2">Name</th>
<th>Response Time</th>
<th>Detail</th>
</tr>
{{- range $loop_index, $record := .Records }}
{{- with $record}}
{{- $status := "error"}}
{{- if .Success }} {{ $status = "success" }} {{ end }}
<tr id="record_{{$suite_index}}_{{$loop_index}}">
<th class={{$status}} style="width:5em;">{{$status}}</th>
<td colspan="2">{{.Name}}</td>
<td style="text-align:center;width:6em;">{{ .Elapsed }} ms</td>
<td class="detail">
<a class="button" href="#popup_log_{{$suite_index}}_{{$loop_index}}">log</a>
<div id="popup_log_{{$suite_index}}_{{$loop_index}}" class="overlay">
<div class="popup">
<h2>Request and Response data</h2>
<a class="close" href="#record_{{$suite_index}}_{{$loop_index}}">&times;</a>
<div class="content">
<h3>Name: {{ .Name }}</h3>
{{- if .Data}}
<h3>Request:</h3>
<div style="overflow: auto">
<table>
{{- range $key, $value := .Data.ReqResps.Request}}
<tr>
<th>{{$key}}</th>
<td align="left">
{{- if eq $key "headers" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "params" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "cookies" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else }}
<pre>{{$value}}</pre>
{{- end }}
</td>
</tr>
{{- end }}
</table>
</div>
<h3>Response:</h3>
<div style="overflow: auto">
<table>
{{- range $key, $value := .Data.ReqResps.Response}}
<tr>
<th>{{$key}}</th>
<td align="left">
{{- if eq $key "headers" }}
{{- range $k, $v := $value}}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else if eq $key "cookies" }}
{{- range $k, $v := $value }}
<pre>{{$k}}: {{$v}}</pre>
{{- end -}}
{{- else }}
<pre>{{ $value }}</pre>
{{- end }}
</td>
</tr>
{{- end }}
</table>
</div>
<h3>Validators:</h3>
<div style="overflow: auto">
{{- if .Data.Validators }}
<table>
<tr>
<th>check</th>
<th>comparator</th>
<th>expect value</th>
<th>actual value</th>
</tr>
{{- range $validator := .Data.Validators }}
<tr>
{{- if eq $validator.CheckResult "pass" }}
<td class="passed">
{{- else if eq $validator.CheckResult "fail" }}
<td class="failed">
{{- else if eq $validator.CheckResult "unchecked" }}
<td class="unchecked">
{{- end }}
{{$validator.Check}}
</td>
<td>{{$validator.Assert}}</td>
<td>{{$validator.Expect}}</td>
<td>{{$validator.CheckValue}}</td>
</tr>
{{- end }}
</table>
{{- end }}
<h3>Statistics:</h3>
<div style="overflow: auto">
<table>
<tr>
<th>content_size(bytes)</th>
<td>{{ .ContentSize }}</td>
</tr>
<tr>
<th>response_time(ms)</th>
<td>{{ .Elapsed }}</td>
</tr>
<tr>
<th>elapsed(ms)</th>
<td>{{ .Elapsed }}</td>
</tr>
</table>
</div>
</div>
{{- end }}
</div>
</div>
</div>
{{ if .Attachments }}
<a class="button" href="#popup_attachment_{{$suite_index}}_{{$loop_index}}">traceback</a>
<div id="popup_attachment_{{$suite_index}}_{{$loop_index}}" class="overlay">
<div class="popup">
<h2>Traceback Message</h2>
<a class="close" href="#record_{{$suite_index}}_{{$loop_index}}">&times;</a>
<div class="content">
<pre>{{ .Attachments }}</pre>
</div>
</div>
</div>
{{- end }}
</td>
</tr>
{{- end }}
{{- end }}
</table>
{{- end }}
</body>

View File

@@ -1 +0,0 @@
# NOTICE: Generated By HttpRunner. DO NOT EDIT!

View File

@@ -1,25 +0,0 @@
{
"config": {
"name": "request methods testcase: empty testcase",
"variables": null,
"verify": false
},
"teststeps": [
{
"name": "",
"variables": null,
"request": {
"method": "GET",
"url": "https://"
},
"validate": [
{
"check": "status_code",
"assert": "equal",
"expect": 200,
"msg": "check status_code"
}
]
}
]
}

View File

@@ -1,13 +0,0 @@
config:
name: "request methods testcase: empty testcase"
variables:
verify: False
teststeps:
- name:
variables:
request:
method: GET
url: "https://"
validate:
- eq: ["status_code", 200]

View File

@@ -1,76 +0,0 @@
{
"config": {
"name": "api test demo",
"variables": {
"user_agent": "iOS/10.3",
"device_sn": "TESTCASE_SETUP_XXX",
"os_platform": "ios",
"app_version": "2.8.6"
},
"base_url": "https://postman-echo.com",
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Host": "postman-echo.com",
"User-Agent": "PostmanRuntime/7.28.4"
},
"verify": false,
"export": [
"session_token"
]
},
"teststeps": [
{
"name": "test api /get",
"api": "api/get.json",
"variables": {
"user_agent": "iOS/10.4",
"device_sn": "$device_sn",
"os_platform": "ios",
"app_version": "2.8.7"
},
"extract": {
"session_token": "body.headers.\"postman-token\""
}
},
{
"name": "test api /post",
"api": "api/post.json",
"variables": {
"user_agent": "iOS/10.5",
"device_sn": "$device_sn",
"os_platform": "ios",
"app_version": "2.8.9"
},
"validate": [
{
"check": "status_code",
"assert": "equal",
"expect": 200,
"msg": "check status_code"
},
{
"check": "body.headers.\"postman-token\"",
"assert": "equal",
"expect": "ea19464c-ddd4-4724-abe9-5e2b254c2723",
"msg": "check body.headers.postman-token"
}
]
},
{
"name": "test api /put",
"api": "api/put.json",
"variables": {
"user_agent": "iOS/10.6",
"device_sn": "$device_sn",
"os_platform": "ios",
"app_version": "2.8.10"
},
"extract": {
"session_token": "body.headers.\"postman-token\""
}
}
]
}

View File

@@ -1,33 +0,0 @@
config:
name: "request methods testcase: reference testcase"
variables:
foo1: testsuite_config_bar1
expect_foo1: testsuite_config_bar1
expect_foo2: config_bar2
base_url: "https://postman-echo.com"
verify: False
teststeps:
-
name: request with functions
variables:
foo1: testcase_ref_bar1
expect_foo1: testcase_ref_bar1
testcase: testcases/requests.yml
export:
- foo3
-
name: post form data
variables:
foo1: bar1
request:
method: POST
url: /post
headers:
User-Agent: ${get_user_agent()}
Content-Type: "application/x-www-form-urlencoded"
body: "foo1=$foo1&foo2=$foo3"
validate:
- eq: ["status_code", 200]
- eq: ["body.form.foo1", "bar1"]
- eq: ["body.form.foo2", "bar21"]

View File

@@ -1,60 +0,0 @@
# NOTE: Generated By HttpRunner v4.0.0
# FROM: testcases/ref_testcase.yml
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
from testcases.demo_requests_test import TestCaseDemoRequests as DemoRequests
class TestCaseDemoRefTestcase(HttpRunner):
config = (
Config("request methods testcase: reference testcase")
.variables(
**{
"foo1": "testsuite_config_bar1",
"expect_foo1": "testsuite_config_bar1",
"expect_foo2": "config_bar2",
}
)
.base_url("https://postman-echo.com")
.verify(False)
)
teststeps = [
Step(
RunTestCase("request with functions")
.with_variables(
**{"foo1": "testcase_ref_bar1", "expect_foo1": "testcase_ref_bar1"}
)
.call(DemoRequests)
.export(*["foo3"])
),
Step(
RunRequest("post form data")
.with_variables(**{"foo1": "bar1"})
.post("/post")
.with_headers(
**{
"User-Agent": "${get_user_agent()}",
"Content-Type": "application/x-www-form-urlencoded",
}
)
.with_data("foo1=$foo1&foo2=$foo3")
.validate()
.assert_equal("status_code", 200)
.assert_equal("body.form.foo1", "bar1")
.assert_equal("body.form.foo2", "bar21")
),
]
if __name__ == "__main__":
TestCaseDemoRefTestcase().test_start()

View File

@@ -1,136 +0,0 @@
{
"config": {
"name": "request methods testcase with functions",
"variables": {
"foo1": "config_bar1",
"foo2": "config_bar2",
"expect_foo1": "config_bar1",
"expect_foo2": "config_bar2"
},
"headers": {
"User-Agent": "${get_user_agent()}"
},
"base_url": "https://postman-echo.com",
"verify": false,
"export": [
"foo3"
]
},
"teststeps": [
{
"name": "get with params",
"variables": {
"foo1": "${ENV(USERNAME)}",
"foo2": "bar21",
"sum_v": "${sum_two_int(10000000, 20000000)}"
},
"request": {
"method": "GET",
"url": "/get",
"params": {
"foo1": "$foo1",
"foo2": "$foo2",
"sum_v": "$sum_v"
}
},
"extract": {
"foo3": "body.args.foo2"
},
"validate": [
{
"check": "status_code",
"assert": "equal",
"expect": 200,
"msg": "check status_code"
},
{
"check": "body.args.foo1",
"assert": "equal",
"expect": "debugtalk",
"msg": "check body.args.foo1"
},
{
"check": "body.args.sum_v",
"assert": "equal",
"expect": "30000000",
"msg": "check body.args.sum_v"
},
{
"check": "body.args.foo2",
"assert": "equal",
"expect": "bar21",
"msg": "check body.args.foo2"
}
]
},
{
"name": "post raw text",
"variables": {
"foo1": "bar12",
"foo3": "bar32"
},
"request": {
"method": "POST",
"url": "/post",
"headers": {
"Content-Type": "text/plain"
},
"body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3."
},
"validate": [
{
"check": "status_code",
"assert": "equal",
"expect": 200,
"msg": "check status_code"
},
{
"check": "body.data",
"assert": "equal",
"expect": "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32.",
"msg": "check body.data"
}
]
},
{
"name": "post form data",
"variables": {
"foo2": "bar23"
},
"request": {
"method": "POST",
"url": "/post",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "foo1=$foo1&foo2=$foo2&foo3=$foo3"
},
"validate": [
{
"check": "status_code",
"assert": "equal",
"expect": 200,
"msg": "check status_code"
},
{
"check": "body.form.foo1",
"assert": "equal",
"expect": "$expect_foo1",
"msg": "check body.form.foo1"
},
{
"check": "body.form.foo2",
"assert": "equal",
"expect": "bar23",
"msg": "check body.form.foo2"
},
{
"check": "body.form.foo3",
"assert": "equal",
"expect": "bar21",
"msg": "check body.form.foo3"
}
]
}
]
}

View File

@@ -1,62 +0,0 @@
config:
name: "request methods testcase with functions"
variables:
foo1: config_bar1
foo2: config_bar2
expect_foo1: config_bar1
expect_foo2: config_bar2
headers:
User-Agent: ${get_user_agent()}
verify: False
export: ["foo3"]
teststeps:
-
name: get with params
variables:
foo1: ${ENV(USERNAME)}
foo2: bar21
sum_v: "${sum_two_int(10000000, 20000000)}"
request:
method: GET
url: $base_url/get
params:
foo1: $foo1
foo2: $foo2
sum_v: $sum_v
extract:
foo3: "body.args.foo2"
validate:
- eq: ["status_code", 200]
- eq: ["body.args.foo1", "debugtalk"]
- eq: ["body.args.sum_v", "30000000"]
- eq: ["body.args.foo2", "bar21"]
-
name: post raw text
variables:
foo1: "bar12"
foo3: "bar32"
request:
method: POST
url: $base_url/post
headers:
Content-Type: "text/plain"
body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3."
validate:
- eq: ["status_code", 200]
- eq: ["body.data", "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32."]
-
name: post form data
variables:
foo2: bar23
request:
method: POST
url: $base_url/post
headers:
Content-Type: "application/x-www-form-urlencoded"
body: "foo1=$foo1&foo2=$foo2&foo3=$foo3"
validate:
- eq: ["status_code", 200]
- eq: ["body.form.foo1", "$expect_foo1"]
- eq: ["body.form.foo2", "bar23"]
- eq: ["body.form.foo3", "bar21"]

View File

@@ -1,83 +0,0 @@
# NOTE: Generated By HttpRunner v4.0.0
# FROM: testcases/requests.yml
from httprunner import HttpRunner, Config, Step, RunRequest
class TestCaseDemoRequests(HttpRunner):
config = (
Config("request methods testcase with functions")
.variables(
**{
"foo1": "config_bar1",
"foo2": "config_bar2",
"expect_foo1": "config_bar1",
"expect_foo2": "config_bar2",
}
)
.base_url("https://postman-echo.com")
.verify(False)
.export(*["foo3"])
)
teststeps = [
Step(
RunRequest("get with params")
.with_variables(
**{"foo1": "bar11", "foo2": "bar21", "sum_v": "${sum_two_int(10000000, 20000000)}"}
)
.get("/get")
.with_params(**{"foo1": "$foo1", "foo2": "$foo2", "sum_v": "$sum_v"})
.with_headers(**{"User-Agent": "${get_user_agent()}"})
.extract()
.with_jmespath("body.args.foo2", "foo3")
.validate()
.assert_equal("status_code", 200)
.assert_equal("body.args.foo1", "bar11")
.assert_equal("body.args.sum_v", "30000000")
.assert_equal("body.args.foo2", "bar21")
),
Step(
RunRequest("post raw text")
.with_variables(**{"foo1": "bar12", "foo3": "bar32"})
.post("/post")
.with_headers(
**{
"User-Agent": "${get_user_agent()}",
"Content-Type": "text/plain",
}
)
.with_data(
"This is expected to be sent back as part of response body: $foo1-$foo2-$foo3."
)
.validate()
.assert_equal("status_code", 200)
.assert_equal(
"body.data",
"This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32.",
)
),
Step(
RunRequest("post form data")
.with_variables(**{"foo2": "bar23"})
.post("/post")
.with_headers(
**{
"User-Agent": "${get_user_agent()}",
"Content-Type": "application/x-www-form-urlencoded",
}
)
.with_data("foo1=$foo1&foo2=$foo2&foo3=$foo3")
.validate()
.assert_equal("status_code", 200)
.assert_equal("body.form.foo1", "$expect_foo1")
.assert_equal("body.form.foo2", "bar23")
.assert_equal("body.form.foo3", "bar21")
),
]
if __name__ == "__main__":
TestCaseDemoRequests().test_start()

View File

@@ -1,176 +0,0 @@
{
"config": {
"name": "demo with complex mechanisms",
"base_url": "https://postman-echo.com",
"variables": {
"a": "${sum(10, 2.3)}",
"b": 3.45,
"n": "${sum_ints(1, 2, 2)}",
"varFoo1": "${gen_random_string($n)}",
"varFoo2": "${max($a, $b)}"
}
},
"teststeps": [
{
"name": "transaction 1 start",
"transaction": {
"name": "tran1",
"type": "start"
}
},
{
"name": "get with params",
"request": {
"method": "GET",
"url": "/get",
"params": {
"foo1": "$varFoo1",
"foo2": "$varFoo2"
},
"headers": {
"User-Agent": "HttpRunnerPlus"
}
},
"variables": {
"b": 34.5,
"n": 3,
"name": "get with params",
"varFoo2": "${max($a, $b)}"
},
"setup_hooks": [
"${setup_hook_example($name)}"
],
"teardown_hooks": [
"${teardown_hook_example($name)}"
],
"extract": {
"varFoo1": "body.args.foo1"
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check response status code"
},
{
"check": "headers.\"Content-Type\"",
"assert": "startswith",
"expect": "application/json"
},
{
"check": "body.args.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "$varFoo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.args.foo2",
"assert": "equals",
"expect": "34.5",
"msg": "check args foo2"
}
]
},
{
"name": "transaction 1 end",
"transaction": {
"name": "tran1",
"type": "end"
}
},
{
"name": "post json data",
"request": {
"method": "POST",
"url": "/post",
"body": {
"foo1": "$varFoo1",
"foo2": "${max($a, $b)}"
}
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check status code"
},
{
"check": "body.json.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.json.foo2",
"assert": "equals",
"expect": 12.3,
"msg": "check args foo2"
}
]
},
{
"name": "post form data",
"request": {
"method": "POST",
"url": "/post",
"headers": {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
},
"body": {
"foo1": "$varFoo1",
"foo2": "${max($a, $b)}",
"time": "${get_timestamp()}"
}
},
"extract": {
"varTime": "body.form.time"
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check status code"
},
{
"check": "body.form.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.form.foo2",
"assert": "equals",
"expect": "12.3",
"msg": "check args foo2"
}
]
},
{
"name": "get with timestamp",
"request": {
"method": "GET",
"url": "/get",
"params": {
"time": "$varTime"
}
},
"validate": [
{
"check": "body.args.time",
"assert": "length_equals",
"expect": 13,
"msg": "check extracted var timestamp"
}
]
}
]
}

View File

@@ -1,114 +0,0 @@
config:
name: demo with complex mechanisms
base_url: https://postman-echo.com
variables:
a: ${sum(10, 2.3)}
b: 3.45
"n": ${sum_ints(1, 2, 2)}
varFoo1: ${gen_random_string($n)}
varFoo2: ${max($a, $b)}
teststeps:
- name: transaction 1 start
transaction:
name: tran1
type: start
- name: get with params
request:
method: GET
url: /get
params:
foo1: $varFoo1
foo2: $varFoo2
headers:
User-Agent: HttpRunnerPlus
variables:
b: 34.5
"n": 3
name: get with params
varFoo2: ${max($a, $b)}
setup_hooks:
- ${setup_hook_example($name)}
teardown_hooks:
- ${teardown_hook_example($name)}
extract:
varFoo1: body.args.foo1
validate:
- check: status_code
assert: equals
expect: 200
msg: check response status code
- check: headers."Content-Type"
assert: startswith
expect: application/json
- check: body.args.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: $varFoo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.args.foo2
assert: equals
expect: "34.5"
msg: check args foo2
- name: transaction 1 end
transaction:
name: tran1
type: end
- name: post json data
request:
method: POST
url: /post
body:
foo1: $varFoo1
foo2: ${max($a, $b)}
validate:
- check: status_code
assert: equals
expect: 200
msg: check status code
- check: body.json.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.json.foo2
assert: equals
expect: 12.3
msg: check args foo2
- name: post form data
request:
method: POST
url: /post
headers:
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
body:
foo1: $varFoo1
foo2: ${max($a, $b)}
time: ${get_timestamp()}
extract:
varTime: body.form.time
validate:
- check: status_code
assert: equals
expect: 200
msg: check status code
- check: body.form.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.form.foo2
assert: equals
expect: "12.3"
msg: check args foo2
- name: get with timestamp
request:
method: GET
url: /get
params:
time: $varTime
validate:
- check: body.args.time
assert: length_equals
expect: 13
msg: check extracted var timestamp

View File

@@ -1,170 +0,0 @@
{
"config": {
"name": "demo without custom function plugin",
"base_url": "https://postman-echo.com",
"variables": {
"a": 12.3,
"b": 3.45,
"n": 5,
"varFoo1": "${gen_random_string($n)}",
"varFoo2": "${max($a, $b)}"
}
},
"teststeps": [
{
"name": "transaction 1 start",
"transaction": {
"name": "tran1",
"type": "start"
}
},
{
"name": "get with params",
"request": {
"method": "GET",
"url": "/get",
"params": {
"foo1": "$varFoo1",
"foo2": "$varFoo2"
},
"headers": {
"User-Agent": "HttpRunnerPlus"
}
},
"variables": {
"b": 34.5,
"n": 3,
"name": "get with params",
"varFoo2": "${max($a, $b)}"
},
"extract": {
"varFoo1": "body.args.foo1"
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check response status code"
},
{
"check": "headers.\"Content-Type\"",
"assert": "startswith",
"expect": "application/json"
},
{
"check": "body.args.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "$varFoo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.args.foo2",
"assert": "equals",
"expect": "34.5",
"msg": "check args foo2"
}
]
},
{
"name": "transaction 1 end",
"transaction": {
"name": "tran1",
"type": "end"
}
},
{
"name": "post json data",
"request": {
"method": "POST",
"url": "/post",
"body": {
"foo1": "$varFoo1",
"foo2": "${max($a, $b)}"
}
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check status code"
},
{
"check": "body.json.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.json.foo2",
"assert": "equals",
"expect": 12.3,
"msg": "check args foo2"
}
]
},
{
"name": "post form data",
"request": {
"method": "POST",
"url": "/post",
"headers": {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
},
"body": {
"foo1": "$varFoo1",
"foo2": "${max($a, $b)}",
"time": "${get_timestamp()}"
}
},
"extract": {
"varTime": "body.form.time"
},
"validate": [
{
"check": "status_code",
"assert": "equals",
"expect": 200,
"msg": "check status code"
},
{
"check": "body.form.foo1",
"assert": "length_equals",
"expect": 5,
"msg": "check args foo1"
},
{
"check": "body.form.foo2",
"assert": "equals",
"expect": "12.3",
"msg": "check args foo2"
}
]
},
{
"name": "get with timestamp",
"request": {
"method": "GET",
"url": "/get",
"params": {
"time": "$varTime"
}
},
"validate": [
{
"check": "body.args.time",
"assert": "length_equals",
"expect": 13,
"msg": "check extracted var timestamp"
}
]
}
]
}

View File

@@ -1,110 +0,0 @@
config:
name: demo without custom function plugin
base_url: https://postman-echo.com
variables:
a: 12.3
b: 3.45
"n": 5
varFoo1: ${gen_random_string($n)}
varFoo2: ${max($a, $b)}
teststeps:
- name: transaction 1 start
transaction:
name: tran1
type: start
- name: get with params
request:
method: GET
url: /get
params:
foo1: $varFoo1
foo2: $varFoo2
headers:
User-Agent: HttpRunnerPlus
variables:
b: 34.5
"n": 3
name: get with params
varFoo2: ${max($a, $b)}
extract:
varFoo1: body.args.foo1
validate:
- check: status_code
assert: equals
expect: 200
msg: check response status code
- check: headers."Content-Type"
assert: startswith
expect: application/json
- check: body.args.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: $varFoo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.args.foo2
assert: equals
expect: "34.5"
msg: check args foo2
- name: transaction 1 end
transaction:
name: tran1
type: end
- name: post json data
request:
method: POST
url: /post
body:
foo1: $varFoo1
foo2: ${max($a, $b)}
validate:
- check: status_code
assert: equals
expect: 200
msg: check status code
- check: body.json.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.json.foo2
assert: equals
expect: 12.3
msg: check args foo2
- name: post form data
request:
method: POST
url: /post
headers:
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
body:
foo1: $varFoo1
foo2: ${max($a, $b)}
time: ${get_timestamp()}
extract:
varTime: body.form.time
validate:
- check: status_code
assert: equals
expect: 200
msg: check status code
- check: body.form.foo1
assert: length_equals
expect: 5
msg: check args foo1
- check: body.form.foo2
assert: equals
expect: "12.3"
msg: check args foo2
- name: get with timestamp
request:
method: GET
url: /get
params:
time: $varTime
validate:
- check: body.args.time
assert: length_equals
expect: 13
msg: check extracted var timestamp

View File

@@ -1,211 +0,0 @@
package sdk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"runtime"
"time"
"github.com/denisbrodbeck/machineid"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
uuid "github.com/satori/go.uuid"
"github.com/httprunner/httprunner/v5/hrp/internal/version"
)
// Measurement Protocol (Google Analytics 4) docs reference:
// https://developers.google.com/analytics/devguides/collection/protocol/ga4
// debugging tools: https://ga-dev-tools.google/ga4/event-builder/
const (
ga4APISecret = "w7lKNQIrQsKNS4ikgMPp0Q"
ga4MeasurementID = "G-9KHR3VC2LN"
)
var (
ga4Client *GA4Client
userID string
)
func init() {
var err error
userID, err = machineid.ProtectedID("hrp")
if err != nil {
userID = uuid.NewV1().String()
}
// init GA4 client
ga4Client = NewGA4Client(ga4MeasurementID, ga4APISecret, false)
}
type GA4Client struct {
apiSecret string // Measurement Protocol API secret value
measurementID string // MEASUREMENT ID, G-XXXXXXXXXX
userID string // A unique identifier for a user
httpClient *http.Client // http client session
debug bool // send events for validation, used for debug
}
// NewGA4Client creates a new GA4Client object with the measurementID and apiSecret.
func NewGA4Client(measurementID, apiSecret string, debug ...bool) *GA4Client {
dbg := false
if len(debug) > 0 {
dbg = debug[0]
}
return &GA4Client{
measurementID: measurementID,
apiSecret: apiSecret,
userID: userID,
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
debug: dbg,
}
}
type Event struct {
// Required. The name for the event.
Name string `json:"name"`
// Optional. The parameters for the event.
// engagement_time_msec/session_id
Params map[string]interface{} `json:"params,omitempty"`
}
// payload docs reference:
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?client_type=gtag
type Payload struct {
// Required. Uniquely identifies a user instance of a web client
ClientID string `json:"client_id"`
// Optional. A unique identifier for a user
UserID string `json:"user_id,omitempty"`
// Optional. A Unix timestamp (in microseconds) for the time to associate with the event.
// This should only be set to record events that happened in the past.
// This value can be overridden via user_property or event timestamps.
// Events can be backdated up to 3 calendar days based on the property's timezone.
TimestampMicros int64 `json:"timestamp_micros,omitempty"`
// Optional. The user properties for the measurement.
UserProperties map[string]string `json:"user_properties,omitempty"`
// Optional. Set to true to indicate these events should not be used for personalized ads.
NonPersonalizedAds bool `json:"non_personalized_ads,omitempty"`
// Required. An array of event items. Up to 25 events can be sent per request.
Events []Event `json:"events"`
}
// validation docs reference:
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events?client_type=gtag
type ValidationResponse struct {
ValidationMessages []ValidationMessage `json:"validationMessages"` // An array of validation messages.
}
type ValidationMessage struct {
FieldPath string `json:"fieldPath"` // The path to the field that was invalid.
Description string `json:"description"` // A description of the error.
ValidationCode ValidationCode `json:"validationCode"` // A ValidationCode that corresponds to the error.
}
type ValidationCode string
const (
VALUE_INVALID ValidationCode = "VALUE_INVALID" // The value provided for a fieldPath was invalid.
VALUE_REQUIRED ValidationCode = "VALUE_REQUIRED" // A required value for a fieldPath was not provided.
NAME_INVALID ValidationCode = "NAME_INVALID" // The name provided was invalid.
NAME_RESERVED ValidationCode = "NAME_RESERVED" // The name provided was one of the reserved names.
VALUE_OUT_OF_BOUNDS ValidationCode = "VALUE_OUT_OF_BOUNDS" // The value provided was too large.
EXCEEDED_MAX_ENTITIES ValidationCode = "EXCEEDED_MAX_ENTITIES" // There were too many parameters in the request.
NAME_DUPLICATED ValidationCode = "NAME_DUPLICATED" // The same name was provided more than once in the request.
)
// SendEvent sends one event to Google Analytics
func (g *GA4Client) SendEvent(event Event) error {
query := url.Values{}
query.Add("api_secret", g.apiSecret)
query.Add("measurement_id", g.measurementID)
var uri string
if g.debug {
uri = fmt.Sprintf("https://www.google-analytics.com/debug/mp/collect?%s", query.Encode())
} else {
uri = fmt.Sprintf("https://www.google-analytics.com/mp/collect?%s", query.Encode())
}
// append event params
if event.Params == nil {
event.Params = map[string]interface{}{}
}
event.Params["os"] = runtime.GOOS
event.Params["arch"] = runtime.GOARCH
event.Params["go_version"] = runtime.Version()
event.Params["hrp_version"] = version.VERSION
payload := Payload{
ClientID: fmt.Sprintf("%d.%d", rand.Int31(), time.Now().Unix()),
UserID: g.userID,
TimestampMicros: time.Now().UnixMicro(),
Events: []Event{event},
}
bs, err := json.Marshal(payload)
if g.debug {
log.Debug().
Str("uri", uri).
Interface("payload", payload).
Msg("send GA4 event")
}
if err != nil {
return errors.Wrap(err, "marshal GA4 request payload failed")
}
body := bytes.NewReader(bs)
res, err := g.httpClient.Post(uri, "application/json", body)
if err != nil {
return errors.Wrap(err, "request GA4 failed")
}
if res.StatusCode >= 300 {
return fmt.Errorf("validation response got unexpected status %d", res.StatusCode)
}
if !g.debug {
return nil
}
bs, err = io.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "read GA4 response body failed")
}
validationResponse := ValidationResponse{}
err = json.Unmarshal(bs, &validationResponse)
if err != nil {
return errors.Wrap(err, "unmarshal GA4 response body failed")
}
log.Debug().
Int("statusCode", res.StatusCode).
Interface("validationResponse", validationResponse).
Msg("get GA4 validation response")
return nil
}
func SendGA4Event(name string, params map[string]interface{}) {
if os.Getenv("DISABLE_GA") == "true" {
// do not send GA4 events in CI environment
return
}
event := Event{
Name: name,
Params: params,
}
err := ga4Client.SendEvent(event)
if err != nil {
log.Error().Err(err).Msg("send GA4 event failed")
}
}

View File

@@ -1,15 +0,0 @@
package sdk
import (
"testing"
)
func TestGA4(t *testing.T) {
ga4Client := NewGA4Client(ga4MeasurementID, ga4APISecret, false)
event := Event{
Name: "hrp_debug_event",
Params: map[string]interface{}{},
}
ga4Client.SendEvent(event)
}

View File

@@ -1,37 +0,0 @@
package sdk
import (
"fmt"
"os"
"github.com/getsentry/sentry-go"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp/internal/version"
)
const (
sentryDSN = "https://cff5efc69b1a4325a4cf873f1e70c13a@o334324.ingest.sentry.io/6070292"
)
func init() {
// init sentry sdk
if os.Getenv("DISABLE_SENTRY") == "true" {
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: sentryDSN,
Release: fmt.Sprintf("httprunner@%s", version.VERSION),
AttachStacktrace: true,
})
if err != nil {
log.Error().Err(err).Msg("init sentry sdk failed!")
return
}
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetLevel(sentry.LevelError)
scope.SetUser(sentry.User{
ID: userID,
})
})
}

View File

@@ -1 +0,0 @@
v5.0.0+2502052133

View File

@@ -1,13 +0,0 @@
package version
import (
_ "embed"
"strings"
)
//go:embed VERSION
var VERSION string
func init() {
VERSION = strings.TrimSpace(VERSION)
}

View File

@@ -1,11 +0,0 @@
package wiki
import (
"github.com/httprunner/funplugin/myexec"
"github.com/rs/zerolog/log"
)
func OpenWiki() error {
log.Info().Msgf("%s https://httprunner.com", openCmd)
return myexec.RunCommand(openCmd, "https://httprunner.com")
}

View File

@@ -1,3 +0,0 @@
package wiki
const openCmd = "open"

View File

@@ -1,3 +0,0 @@
package wiki
const openCmd = "xdg-open"

View File

@@ -1,3 +0,0 @@
package wiki
const openCmd = "explorer"

View File

@@ -1,163 +0,0 @@
package hrp
import (
"bytes"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/json"
)
// LoadTestCases load testcases from TestCasePath or TestCase
func LoadTestCases(tests ...ITestCase) ([]*TestCase, error) {
testCases := make([]*TestCase, 0)
for _, iTestCase := range tests {
if testcase, ok := iTestCase.(*TestCase); ok {
testCases = append(testCases, testcase)
continue
}
if testcase, ok := iTestCase.(*TestCaseJSON); ok {
tc, err := testcase.GetTestCase()
if err != nil {
return nil, err
}
testCases = append(testCases, tc)
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 := string(*tcPath)
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.GetTestCase()
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("load testcase failed")
return nil
}
testCases = append(testCases, tc)
return nil
})
if err != nil {
return nil, errors.Wrap(err, "read dir failed")
}
}
if len(testCases) < 1 {
return nil, errors.New("test case count less than 1 or parse error")
}
log.Info().Int("count", len(testCases)).Msg("load testcases successfully")
return testCases, nil
}
// LoadFileObject loads file content with file extension and assigns to structObj
func LoadFileObject(path string, structObj interface{}) (err error) {
log.Info().Str("path", path).Msg("load file")
file, err := readFile(path)
if err != nil {
return errors.Wrap(err, "read file failed")
}
// remove BOM at the beginning of file
file = bytes.TrimLeft(file, "\xef\xbb\xbf")
ext := filepath.Ext(path)
switch ext {
case ".json", ".har":
decoder := json.NewDecoder(bytes.NewReader(file))
decoder.UseNumber()
err = decoder.Decode(structObj)
if err != nil {
err = errors.Wrap(code.LoadJSONError, err.Error())
}
case ".yaml", ".yml":
err = yaml.Unmarshal(file, structObj)
if err != nil {
err = errors.Wrap(code.LoadYAMLError, err.Error())
}
case ".env":
err = parseEnvContent(file, structObj)
if err != nil {
err = errors.Wrap(code.LoadEnvError, err.Error())
}
default:
err = code.UnsupportedFileExtension
}
return err
}
func parseEnvContent(file []byte, obj interface{}) error {
envMap := obj.(map[string]string)
lines := strings.Split(string(file), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
// empty line or comment line
continue
}
var kv []string
if strings.Contains(line, "=") {
kv = strings.SplitN(line, "=", 2)
} else if strings.Contains(line, ":") {
kv = strings.SplitN(line, ":", 2)
}
if len(kv) != 2 {
return errors.New(".env format error")
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
envMap[key] = value
// set env
log.Info().Str("key", key).Msg("set env")
os.Setenv(key, value)
}
return nil
}
func readFile(path string) ([]byte, error) {
var err error
path, err = filepath.Abs(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("convert absolute path failed")
return nil, errors.Wrap(code.LoadFileError, err.Error())
}
file, err := os.ReadFile(path)
if err != nil {
log.Error().Err(err).Msg("read file failed")
return nil, errors.Wrap(code.LoadFileError, err.Error())
}
return file, nil
}

View File

@@ -1,94 +0,0 @@
package hrp
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadTestCases(t *testing.T) {
// load test cases from folder path
tc := TestCasePath("../examples/demo-with-py-plugin/testcases/")
testCases, err := LoadTestCases(&tc)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, 4, len(testCases)) {
t.Fatal()
}
// load test cases from folder path, including sub folders
tc = TestCasePath("../examples/demo-with-py-plugin/")
testCases, err = LoadTestCases(&tc)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, 4, len(testCases)) {
t.Fatal()
}
// load test cases from single file path
tc = TestCasePath(demoTestCaseWithPluginJSONPath)
testCases, err = LoadTestCases(&tc)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, 1, len(testCases)) {
t.Fatal()
}
// load test cases from TestCase instance
testcase := &TestCase{
Config: NewConfig("TestCase").SetWeight(3),
}
testCases, err = LoadTestCases(testcase)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, len(testCases), 1) {
t.Fatal()
}
// load test cases from TestCaseJSON
testcaseJSON := TestCaseJSON(`
{
"config":{"name":"TestCaseJSON"},
"teststeps":[
{"name": "step1", "request":{"url": "https://httpbin.org/get"}},
{"name": "step2", "shell":{"string": "ls -l"}}
]
}`)
testCases, err = LoadTestCases(&testcaseJSON)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, len(testCases), 1) {
t.Fatal()
}
}
func TestLoadCase(t *testing.T) {
tcJSON := &TestCaseDef{}
tcYAML := &TestCaseDef{}
err := LoadFileObject(demoTestCaseWithPluginJSONPath, tcJSON)
if !assert.NoError(t, err) {
t.Fatal()
}
err = LoadFileObject(demoTestCaseWithPluginYAMLPath, tcYAML)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, tcJSON.Config.Name, tcYAML.Config.Name) {
t.Fatal()
}
if !assert.Equal(t, tcJSON.Config.BaseURL, tcYAML.Config.BaseURL) {
t.Fatal()
}
if !assert.Equal(t, tcJSON.Steps[1].StepName, tcYAML.Steps[1].StepName) {
t.Fatal()
}
if !assert.Equal(t, tcJSON.Steps[1].Request, tcJSON.Steps[1].Request) {
t.Fatal()
}
}

View File

@@ -1,63 +0,0 @@
package hrp
import (
"io"
"os"
"runtime"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog/pkgerrors"
)
func InitLogger(logLevel string, logJSON bool) {
// Error Logging with Stacktrace
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
// set log timestamp precise to milliseconds
zerolog.TimeFieldFormat = "2006-01-02T15:04:05.999Z0700"
// init log writer
var msg string
var writer io.Writer
if !logJSON {
// log a human-friendly, colorized output
noColor := false
if runtime.GOOS == "windows" {
noColor = true
}
writer = zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: time.RFC3339Nano,
NoColor: noColor,
}
msg = "log with colorized console"
} else {
// default logger
writer = os.Stderr
msg = "log with json output"
}
log.Logger = zerolog.New(writer).With().Timestamp().Logger()
log.Info().Msg(msg)
// Setting Global Log Level
level := strings.ToUpper(logLevel)
log.Info().Str("log_level", level).Msg("set global log level")
switch level {
case "DEBUG":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "INFO":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "WARN":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "ERROR":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
case "FATAL":
zerolog.SetGlobalLevel(zerolog.FatalLevel)
case "PANIC":
zerolog.SetGlobalLevel(zerolog.PanicLevel)
}
}

View File

@@ -1,390 +0,0 @@
package hrp
import (
"math/rand"
"reflect"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type TParamsConfig struct {
PickOrder iteratorPickOrder `json:"pick_order,omitempty" yaml:"pick_order,omitempty"` // overall pick-order strategy
Strategies map[string]iteratorStrategy `json:"strategies,omitempty" yaml:"strategies,omitempty"` // individual strategies for each parameters
Limit int `json:"limit,omitempty" yaml:"limit,omitempty"`
}
type iteratorPickOrder string
const (
pickOrderSequential iteratorPickOrder = "sequential"
pickOrderRandom iteratorPickOrder = "random"
pickOrderUnique iteratorPickOrder = "unique"
)
/*
[
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
]
*/
type Parameters []map[string]interface{}
type iteratorStrategy struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
PickOrder iteratorPickOrder `json:"pick_order,omitempty" yaml:"pick_order,omitempty"`
}
func (p *Parser) initParametersIterator(cfg *TConfig) (*ParametersIterator, error) {
parameters, err := p.loadParameters(cfg.Parameters, cfg.Variables)
if err != nil {
return nil, err
}
return newParametersIterator(parameters, cfg.ParametersSetting), nil
}
func newParametersIterator(parameters map[string]Parameters, config *TParamsConfig) *ParametersIterator {
if config == nil {
config = &TParamsConfig{}
}
iterator := &ParametersIterator{
data: parameters,
hasNext: true,
sequentialParameters: nil,
randomParameterNames: nil,
limit: config.Limit,
index: 0,
}
if len(parameters) == 0 {
iterator.data = map[string]Parameters{}
iterator.limit = 1
return iterator
}
parametersList := make([]Parameters, 0)
for paramName := range parameters {
// check parameter individual pick order strategy
strategy, ok := config.Strategies[paramName]
if !ok || strategy.PickOrder == "" {
// default to overall pick order strategy
strategy.PickOrder = config.PickOrder
}
// group parameters by pick order strategy
if strategy.PickOrder == pickOrderRandom {
iterator.randomParameterNames = append(iterator.randomParameterNames, paramName)
} else {
parametersList = append(parametersList, parameters[paramName])
}
}
// generate cartesian product for sequential parameters
iterator.sequentialParameters = genCartesianProduct(parametersList)
if iterator.limit < 0 {
log.Warn().Msg("parameters unlimited mode is only supported for load testing")
iterator.limit = 0
}
if iterator.limit == 0 {
// limit not set
if len(iterator.sequentialParameters) > 0 {
// use cartesian product of sequential parameters size as limit
iterator.limit = len(iterator.sequentialParameters)
} else {
// all parameters are selected by random
// only run once
iterator.limit = 1
}
} else { // limit > 0
log.Info().Int("limit", iterator.limit).Msg("set limit for parameters")
}
return iterator
}
type ParametersIterator struct {
sync.Mutex
data map[string]Parameters
hasNext bool // cache query result
sequentialParameters Parameters // cartesian product for sequential parameters
randomParameterNames []string // value is parameter names
limit int // limit count for iteration
index int // current iteration index
}
// SetUnlimitedMode is used for load testing
func (iter *ParametersIterator) SetUnlimitedMode() {
log.Info().Msg("set parameters unlimited mode")
iter.limit = -1
}
func (iter *ParametersIterator) HasNext() bool {
if !iter.hasNext {
return false
}
// unlimited mode
if iter.limit == -1 {
return true
}
// reached limit
if iter.index >= iter.limit {
// cache query result
iter.hasNext = false
return false
}
return true
}
func (iter *ParametersIterator) Next() map[string]interface{} {
iter.Lock()
defer iter.Unlock()
if !iter.hasNext {
return nil
}
var selectedParameters map[string]interface{}
if len(iter.sequentialParameters) == 0 {
selectedParameters = make(map[string]interface{})
} else if iter.index < len(iter.sequentialParameters) {
selectedParameters = iter.sequentialParameters[iter.index]
} else {
// loop back to the first sequential parameter
index := iter.index % len(iter.sequentialParameters)
selectedParameters = iter.sequentialParameters[index]
}
// merge with random parameters
for _, paramName := range iter.randomParameterNames {
randSource := rand.New(rand.NewSource(time.Now().UnixNano()))
randIndex := randSource.Intn(len(iter.data[paramName]))
for k, v := range iter.data[paramName][randIndex] {
selectedParameters[k] = v
}
}
iter.index++
if iter.limit > 0 && iter.index >= iter.limit {
iter.hasNext = false
}
return selectedParameters
}
func (iter *ParametersIterator) Data() map[string]interface{} {
res := map[string]interface{}{}
for key, params := range iter.data {
res[key] = params
}
return res
}
func genCartesianProduct(multiParameters []Parameters) Parameters {
if len(multiParameters) == 0 {
return nil
}
cartesianProduct := multiParameters[0]
for i := 0; i < len(multiParameters)-1; i++ {
var tempProduct Parameters
for _, param1 := range cartesianProduct {
for _, param2 := range multiParameters[i+1] {
tempProduct = append(tempProduct, mergeVariables(param1, param2))
}
}
cartesianProduct = tempProduct
}
return cartesianProduct
}
/*
loadParameters loads parameters from multiple sources.
parameter value may be in three types:
(1) data list, e.g. ["iOS/10.1", "iOS/10.2", "iOS/10.3"]
(2) call built-in parameterize function, "${parameterize(account.csv)}"
(3) call custom function in debugtalk.py, "${gen_app_version()}"
configParameters = {
"user_agent": ["iOS/10.1", "iOS/10.2", "iOS/10.3"], // case 1
"username-password": "${parameterize(account.csv)}", // case 2
"app_version": "${gen_app_version()}", // case 3
}
=>
{
"user_agent": [
{"user_agent": "iOS/10.1"},
{"user_agent": "iOS/10.2"},
{"user_agent": "iOS/10.3"},
],
"username-password": [
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
],
"app_version": [
{"app_version": "1.0.0"},
{"app_version": "1.0.1"},
]
}
*/
func (p *Parser) loadParameters(configParameters map[string]interface{}, variablesMapping map[string]interface{}) (
map[string]Parameters, error) {
if len(configParameters) == 0 {
return nil, nil
}
parsedParameters := make(map[string]Parameters)
for k, v := range configParameters {
var parametersRawList interface{}
rawValue := reflect.ValueOf(v)
switch rawValue.Kind() {
case reflect.Slice:
// case 1
// e.g. user_agent: ["iOS/10.1", "iOS/10.2"]
// => ["iOS/10.1", "iOS/10.2"]
parametersRawList = rawValue.Interface()
case reflect.String:
// case 2 or case 3
// e.g. username-password: ${parameterize(examples/hrp/account.csv)}
// => [{"username": "test1", "password": "111111"}, {"username": "test2", "password": "222222"}]
// => [["test1", "111111"], ["test2", "222222"]]
// e.g. "app_version": "${gen_app_version()}"
// => ["1.0.0", "1.0.1"]
parsedParameterContent, err := p.ParseString(rawValue.String(), variablesMapping)
if err != nil {
log.Error().Err(err).
Str("parametersRawContent", rawValue.String()).
Msg("parse parameters content failed")
return nil, err
}
parsedParameterRawValue := reflect.ValueOf(parsedParameterContent)
if parsedParameterRawValue.Kind() != reflect.Slice {
log.Error().
Interface("parsedParameterContent", parsedParameterRawValue).
Msg("parsed parameters content is not slice")
return nil, errors.New("parsed parameters content should be slice")
}
parametersRawList = parsedParameterRawValue.Interface()
default:
log.Error().
Interface("parameters", configParameters).
Msg("config parameters raw value should be slice or string (functions call)")
return nil, errors.New("config parameters raw value format error")
}
parameterSlice, err := convertParameters(k, parametersRawList)
if err != nil {
return nil, err
}
parsedParameters[k] = parameterSlice
}
return parsedParameters, nil
}
/*
convert parameters to standard format
key and parametersRawList may be in three types:
case 1:
key = "user_agent"
parametersRawList = ["iOS/10.1", "iOS/10.2"]
case 2:
key = "username-password"
parametersRawList = [{"username": "test1", "password": "111111"}, {"username": "test2", "password": "222222"}]
case 3:
key = "username-password"
parametersRawList = [["test1", "111111"], ["test2", "222222"]]
*/
func convertParameters(key string, parametersRawList interface{}) (parameterSlice []map[string]interface{}, err error) {
parametersRawSlice := reflect.ValueOf(parametersRawList)
if parametersRawSlice.Kind() != reflect.Slice {
return nil, errors.New("parameters raw value is not list")
}
// ["user_agent"], ["username", "password"], ["app_version"]
parameterNames := strings.Split(key, "-")
for i := 0; i < parametersRawSlice.Len(); i++ {
parametersLine := make(map[string]interface{})
elem := parametersRawSlice.Index(i)
// e.g. Type: interface{} | []interface{}, convert interface{} to []interface{}
if elem.Kind() == reflect.Interface {
elem = elem.Elem()
}
switch elem.Kind() {
case reflect.Slice:
// case 3
// e.g. "username-password": ["test1", "111111"]
// => {"username": "test1", "password": "111111"}
if len(parameterNames) != elem.Len() {
log.Error().
Strs("parameterNames", parameterNames).
Int("lineIndex", i).
Interface("content", elem.Interface()).
Msg("parameters line length does not match to names length")
return nil, errors.New("parameters line length does not match to names length")
}
for j := 0; j < elem.Len(); j++ {
parametersLine[parameterNames[j]] = elem.Index(j).Interface()
}
case reflect.Map:
// case 2
// e.g. "username-password": {"username": "test1", "password": "111111", "other": "111"}
// => {"username": "test1", "password": "passwd1"}
for _, name := range parameterNames {
lineMap := elem.Interface().(map[string]interface{})
if _, ok := lineMap[name]; ok {
parametersLine[name] = elem.MapIndex(reflect.ValueOf(name)).Interface()
} else {
log.Error().
Strs("parameterNames", parameterNames).
Str("name", name).
Msg("parameter name not found")
return nil, errors.New("parameter name not found")
}
}
default:
// case 1
// e.g. "user_agent": "iOS/10.1"
// -> {"user_agent": "iOS/10.1"}
if len(parameterNames) != 1 {
log.Error().
Strs("parameterNames", parameterNames).
Int("lineIndex", i).
Msg("parameters format error")
return nil, errors.New("parameters format error")
}
parametersLine[parameterNames[0]] = elem.Interface()
}
parameterSlice = append(parameterSlice, parametersLine)
}
return parameterSlice, nil
}

View File

@@ -1,538 +0,0 @@
package hrp
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadParameters(t *testing.T) {
testData := []struct {
configParameters map[string]interface{}
loadedParameters map[string]Parameters
}{
{
map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/$file)}", hrpExamplesDir),
},
map[string]Parameters{
"username-password": {
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
{"username": "test3", "password": "333333"},
},
},
},
{
map[string]interface{}{
"username-password": [][]interface{}{
{"test1", "111111"},
{"test2", "222222"},
},
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"},
"app_version": []interface{}{4.0},
},
map[string]Parameters{
"username-password": {
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
},
"user_agent": {
{"user_agent": "iOS/10.1"},
{"user_agent": "iOS/10.2"},
},
"app_version": {
{"app_version": 4.0},
},
},
},
{
map[string]interface{}{
"username-password": []interface{}{
[]interface{}{"test1", "111111"},
[]interface{}{"test2", "222222"},
},
},
map[string]Parameters{
"username-password": {
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
},
},
},
{
map[string]interface{}{},
nil,
},
{
nil,
nil,
},
}
variablesMapping := map[string]interface{}{
"file": "account.csv",
}
parser := newParser()
for _, data := range testData {
value, err := parser.loadParameters(data.configParameters, variablesMapping)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.loadedParameters, value) {
t.Fatal()
}
}
}
func TestLoadParametersError(t *testing.T) {
testData := []struct {
configParameters map[string]interface{}
}{
{
map[string]interface{}{
"username_password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir),
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
{
map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir),
"user-agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
{
map[string]interface{}{
"username-password": fmt.Sprintf("${param(%s/account.csv)}", hrpExamplesDir),
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
}
parser := newParser()
for _, data := range testData {
_, err := parser.loadParameters(data.configParameters, map[string]interface{}{})
if !assert.Error(t, err) {
t.Fatal()
}
}
}
func TestInitParametersIteratorCount(t *testing.T) {
configParameters := map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir), // 3
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"}, // 2
"app_version": []interface{}{4.0}, // 1
}
testData := []struct {
cfg *TConfig
expectLimit int
}{
// default, no parameters setting
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{},
},
6, // 3 * 2 * 1
},
{
&TConfig{
Parameters: configParameters,
},
6, // 3 * 2 * 1
},
// default equals to set overall parameters pick-order to "sequential"
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
PickOrder: "sequential",
},
},
6, // 3 * 2 * 1
},
// default equals to set each individual parameters pick-order to "sequential"
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
Strategies: map[string]iteratorStrategy{
"username-password": {Name: "user-info", PickOrder: "sequential"},
"user_agent": {Name: "user-identity", PickOrder: "sequential"},
"app_version": {Name: "app-version", PickOrder: "sequential"},
},
},
},
6, // 3 * 2 * 1
},
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
Strategies: map[string]iteratorStrategy{
"user_agent": {Name: "user-identity", PickOrder: "sequential"},
"app_version": {Name: "app-version", PickOrder: "sequential"},
},
},
},
6, // 3 * 2 * 1
},
// set overall parameters overall pick-order to "random"
// each random parameters only select one item
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
PickOrder: "random",
},
},
1, // 1 * 1 * 1
},
// set some individual parameters pick-order to "random"
// this will override overall strategy
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
Strategies: map[string]iteratorStrategy{
"user_agent": {Name: "user-identity", PickOrder: "random"},
},
},
},
3, // 3 * 1 * 1
},
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{
Strategies: map[string]iteratorStrategy{
"username-password": {Name: "user-info", PickOrder: "random"},
},
},
},
2, // 1 * 2 * 1
},
// set limit for parameters
{
&TConfig{
Parameters: configParameters, // total: 6 = 3 * 2 * 1
ParametersSetting: &TParamsConfig{
Limit: 4, // limit could be less than total
},
},
4,
},
{
&TConfig{
Parameters: configParameters, // total: 6 = 3 * 2 * 1
ParametersSetting: &TParamsConfig{
Limit: 9, // limit could also be greater than total
},
},
9,
},
// no parameters
// also will generate one empty item
{
&TConfig{
Parameters: nil,
ParametersSetting: nil,
},
1,
},
}
parser := newParser()
for _, data := range testData {
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expectLimit, iterator.limit) {
t.Fatal()
}
for i := 0; i < data.expectLimit; i++ {
if !assert.True(t, iterator.HasNext()) {
t.Fatal()
}
iterator.Next() // consume next parameters
}
// should not have next
if !assert.False(t, iterator.HasNext()) {
t.Fatal()
}
}
}
func TestInitParametersIteratorUnlimitedCount(t *testing.T) {
configParameters := map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir), // 3
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"}, // 2
"app_version": []interface{}{4.0}, // 1
}
testData := []struct {
cfg *TConfig
}{
// default, no parameters setting
{
&TConfig{
Parameters: configParameters,
ParametersSetting: &TParamsConfig{},
},
},
// no parameters
// also will generate one empty item
{
&TConfig{
Parameters: nil,
ParametersSetting: nil,
},
},
}
parser := newParser()
for _, data := range testData {
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}
// set unlimited mode
iterator.SetUnlimitedMode()
if !assert.Equal(t, -1, iterator.limit) {
t.Fatal()
}
for i := 0; i < 100; i++ {
if !assert.True(t, iterator.HasNext()) {
t.Fatal()
}
iterator.Next() // consume next parameters
}
if !assert.Equal(t, 100, iterator.index) {
t.Fatal()
}
// should also have next
if !assert.True(t, iterator.HasNext()) {
t.Fatal()
}
}
}
func TestInitParametersIteratorContent(t *testing.T) {
configParameters := map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir), // 3
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"}, // 2
"app_version": []interface{}{4.0}, // 1
}
testData := []struct {
cfg *TConfig
checkIndex int
expectParameters map[string]interface{}
}{
// default, no parameters setting
{
&TConfig{
Parameters: configParameters,
},
0, // check first item
map[string]interface{}{
"username": "test1", "password": "111111", "user_agent": "iOS/10.1", "app_version": 4.0,
},
},
// set limit for parameters
{
&TConfig{
Parameters: map[string]interface{}{
"username-password": []map[string]interface{}{ // 1
{"username": "test1", "password": 111111, "other": "111"},
},
"user_agent": []string{"iOS/10.1", "iOS/10.2"}, // 2
},
ParametersSetting: &TParamsConfig{
Limit: 5, // limit could also be greater than total
Strategies: map[string]iteratorStrategy{
"username-password": {Name: "user-info", PickOrder: "random"},
},
},
},
2, // check 3th item, equals to the first item
map[string]interface{}{
"username": "test1", "password": 111111, "user_agent": "iOS/10.1",
},
},
// no parameters
// also will generate one empty item
{
&TConfig{
Parameters: nil,
ParametersSetting: nil,
},
0,
map[string]interface{}{},
},
}
parser := newParser()
for _, data := range testData {
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}
// get expected parameters item
for i := 0; i < data.checkIndex; i++ {
if !assert.True(t, iterator.HasNext()) {
t.Fatal()
}
iterator.Next() // consume next parameters
}
parametersItem := iterator.Next()
if !assert.Equal(t, data.expectParameters, parametersItem) {
t.Fatal()
}
}
}
func TestGenCartesianProduct(t *testing.T) {
testData := []struct {
multiParameters []Parameters
expect Parameters
}{
{
[]Parameters{
{
{"app_version": 4.0},
},
{
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
},
{
{"user_agent": "iOS/10.1"},
{"user_agent": "iOS/10.2"},
},
},
Parameters{
{"app_version": 4.0, "password": "111111", "user_agent": "iOS/10.1", "username": "test1"},
{"app_version": 4.0, "password": "111111", "user_agent": "iOS/10.2", "username": "test1"},
{"app_version": 4.0, "password": "222222", "user_agent": "iOS/10.1", "username": "test2"},
{"app_version": 4.0, "password": "222222", "user_agent": "iOS/10.2", "username": "test2"},
},
},
{
nil,
nil,
},
{
[]Parameters{},
nil,
},
}
for _, data := range testData {
parameters := genCartesianProduct(data.multiParameters)
if !assert.Equal(t, data.expect, parameters) {
t.Fatal()
}
}
}
func TestConvertParameters(t *testing.T) {
testData := []struct {
key string
parametersRawList interface{}
expect []map[string]interface{}
}{
{
"username-password",
[]map[string]interface{}{
{"username": "test1", "password": 111111, "other": "111"},
{"username": "test2", "password": 222222, "other": "222"},
},
[]map[string]interface{}{
{"username": "test1", "password": 111111},
{"username": "test2", "password": 222222},
},
},
{
"username-password",
[][]string{
{"test1", "111111"},
{"test2", "222222"},
},
[]map[string]interface{}{
{"username": "test1", "password": "111111"},
{"username": "test2", "password": "222222"},
},
},
{
"app_version",
[]float64{3.1, 3.0},
[]map[string]interface{}{
{"app_version": 3.1},
{"app_version": 3.0},
},
},
{
"user_agent",
[]string{"iOS/10.1", "iOS/10.2"},
[]map[string]interface{}{
{"user_agent": "iOS/10.1"},
{"user_agent": "iOS/10.2"},
},
},
}
for _, data := range testData {
value, err := convertParameters(data.key, data.parametersRawList)
if !assert.Nil(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, value) {
t.Fatal()
}
}
}
func TestConvertParametersError(t *testing.T) {
testData := []struct {
key string
parametersRawList interface{}
}{
{
"app_version",
123, // not slice
},
{
"app_version",
"123", // not slice
},
{
"username-password",
[]map[string]interface{}{ // parameter names not match
{"username": "test1", "other": "111"},
{"username": "test2", "other": "222"},
},
},
{
"username-password",
[][]string{ // parameter names length not match
{"test1"},
{"test2"},
},
},
}
for _, data := range testData {
_, err := convertParameters(data.key, data.parametersRawList)
if !assert.Error(t, err) {
t.Fatal()
}
}
}

View File

@@ -1,568 +0,0 @@
package hrp
import (
builtinJSON "encoding/json"
"fmt"
"net/url"
"path"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/httprunner/funplugin"
"github.com/httprunner/funplugin/fungo"
"github.com/maja42/goval"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp/code"
"github.com/httprunner/httprunner/v5/hrp/internal/builtin"
)
func newParser() *Parser {
return &Parser{}
}
type Parser struct {
Plugin funplugin.IPlugin // plugin is used to call functions
}
func buildURL(baseURL, stepURL string, queryParams url.Values) (fullUrl *url.URL) {
uStep, err := url.Parse(stepURL)
if err != nil {
log.Error().Str("stepURL", stepURL).Err(err).Msg("[buildURL] parse url failed")
return nil
}
defer func() {
// append query params
if paramStr := queryParams.Encode(); paramStr != "" {
if uStep.RawQuery == "" {
uStep.RawQuery = paramStr
} else {
uStep.RawQuery = uStep.RawQuery + "&" + paramStr
}
}
// ensure path suffix '/' exists
if uStep.RawQuery == "" {
uStep.Path = strings.TrimRight(uStep.Path, "/") + "/"
}
fullUrl = uStep
}()
// step url is absolute url
if uStep.Host != "" {
return uStep
}
// step url is relative, based on base url
uConfig, err := url.Parse(baseURL)
if err != nil {
log.Error().Str("baseURL", baseURL).Err(err).Msg("[buildURL] parse url failed")
return
}
// merge url
uStep.Scheme = uConfig.Scheme
uStep.Host = uConfig.Host
uStep.Path = path.Join(uConfig.Path, uStep.Path)
return uStep
}
func (p *Parser) ParseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) {
parsedHeaders := make(map[string]string)
headers, err := p.Parse(rawHeaders, variablesMapping)
if err != nil {
return rawHeaders, err
}
for k, v := range headers.(map[string]interface{}) {
parsedHeaders[k] = convertString(v)
}
return parsedHeaders, nil
}
func convertString(raw interface{}) string {
if str, ok := raw.(string); ok {
return str
}
if float, ok := raw.(float64); ok {
// f: avoid conversion to exponential notation
return strconv.FormatFloat(float, 'f', -1, 64)
}
// convert to string
return fmt.Sprintf("%v", raw)
}
func (p *Parser) Parse(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) {
rawValue := reflect.ValueOf(raw)
switch rawValue.Kind() {
case reflect.String:
// json.Number
if rawValue, ok := raw.(builtinJSON.Number); ok {
return parseJSONNumber(rawValue)
}
// other string
value := rawValue.String()
value = strings.TrimSpace(value)
return p.ParseString(value, variablesMapping)
case reflect.Slice:
parsedSlice := make([]interface{}, rawValue.Len())
for i := 0; i < rawValue.Len(); i++ {
parsedValue, err := p.Parse(rawValue.Index(i).Interface(), variablesMapping)
if err != nil {
return raw, err
}
parsedSlice[i] = parsedValue
}
return parsedSlice, nil
case reflect.Map: // convert any map to map[string]interface{}
parsedMap := make(map[string]interface{})
for _, k := range rawValue.MapKeys() {
parsedKey, err := p.ParseString(k.String(), variablesMapping)
if err != nil {
return raw, err
}
v := rawValue.MapIndex(k)
parsedValue, err := p.Parse(v.Interface(), variablesMapping)
if err != nil {
return raw, err
}
key := convertString(parsedKey)
parsedMap[key] = parsedValue
}
return parsedMap, nil
default:
// other types, e.g. nil, int, float, bool
return builtin.TypeNormalization(raw), nil
}
}
func parseJSONNumber(raw builtinJSON.Number) (value interface{}, err error) {
if strings.Contains(raw.String(), ".") {
// float64
value, err = raw.Float64()
} else {
// int64
value, err = raw.Int64()
}
if err != nil {
return nil, errors.Wrap(code.ParseError,
fmt.Sprintf("parse json number failed: %v", err))
}
return value, nil
}
const (
regexVariable = `[a-zA-Z_]\w*` // variable name should start with a letter or underscore
regexFunctionName = `[a-zA-Z_]\w*` // function name should start with a letter or underscore
regexNumber = `^-?\d+(\.\d+)?$` // match number, e.g. 123, -123, 1.23, -1.23
)
var (
regexCompileVariable = regexp.MustCompile(fmt.Sprintf(`\$\{(%s)\}|\$(%s)`, regexVariable, regexVariable)) // parse ${var} or $var
regexCompileFunction = regexp.MustCompile(fmt.Sprintf(`\$\{(%s)\(([\$\w\.\-/\s=,]*)\)\}`, regexFunctionName)) // parse ${func1($a, $b)}
regexCompileNumber = regexp.MustCompile(regexNumber) // parse number
)
// ParseString parse string with variables
func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) {
matchStartPosition := 0
parsedString := ""
remainedString := raw
for matchStartPosition < len(raw) {
// locate $ char position
startPosition := strings.Index(remainedString, "$")
if startPosition == -1 { // no $ found
// append remained string
parsedString += remainedString
break
}
// found $, check if variable or function
matchStartPosition += startPosition
parsedString += remainedString[0:startPosition]
remainedString = remainedString[startPosition:]
// Notice: notation priority
// $$ > ${func($a, $b)} > $var
// search $$, use $$ to escape $ notation
if strings.HasPrefix(remainedString, "$$") { // found $$
matchStartPosition += 2
parsedString += "$"
remainedString = remainedString[2:]
continue
}
// search function like ${func($a, $b)}
funcMatched := regexCompileFunction.FindStringSubmatch(remainedString)
if len(funcMatched) == 3 {
funcName := funcMatched[1]
argsStr := funcMatched[2]
arguments, err := parseFunctionArguments(argsStr)
if err != nil {
return raw, errors.Wrap(code.ParseFunctionError, err.Error())
}
parsedArgs, err := p.Parse(arguments, variablesMapping)
if err != nil {
return raw, err
}
result, err := p.callFunc(funcName, parsedArgs.([]interface{})...)
if err != nil {
log.Error().Str("funcName", funcName).Interface("arguments", arguments).
Err(err).Msg("call function failed")
return raw, errors.Wrap(code.CallFunctionError, err.Error())
}
log.Info().Str("funcName", funcName).Interface("arguments", arguments).
Interface("output", result).Msg("call function success")
if funcMatched[0] == raw {
// raw_string is a function, e.g. "${add_one(3)}", return its eval value directly
return result, nil
}
// raw_string contains one or many functions, e.g. "abc${add_one(3)}def"
matchStartPosition += len(funcMatched[0])
parsedString += convertString(result)
remainedString = raw[matchStartPosition:]
log.Debug().
Str("parsedString", parsedString).
Int("matchStartPosition", matchStartPosition).
Msg("[parseString] parse function")
continue
}
// search variable like ${var} or $var
varMatched := regexCompileVariable.FindStringSubmatch(remainedString)
if len(varMatched) == 3 {
var varName string
if varMatched[1] != "" {
varName = varMatched[1] // match ${var}
} else {
varName = varMatched[2] // match $var
}
varValue, ok := variablesMapping[varName]
if !ok {
return raw, errors.Wrap(code.VariableNotFound,
fmt.Sprintf("variable %s not found", varName))
}
if fmt.Sprintf("${%s}", varName) == raw || fmt.Sprintf("$%s", varName) == raw {
// raw string is a variable, $var or ${var}, return its value directly
return varValue, nil
}
matchStartPosition += len(varMatched[0])
parsedString += convertString(varValue)
remainedString = raw[matchStartPosition:]
log.Debug().
Str("parsedString", parsedString).
Int("matchStartPosition", matchStartPosition).
Msg("[parseString] parse variable")
continue
}
parsedString += remainedString
break
}
return parsedString, nil
}
// callFunc calls function with arguments
// only support return at most one result value
func (p *Parser) callFunc(funcName string, arguments ...interface{}) (interface{}, error) {
// call with plugin function
if p.Plugin != nil {
if p.Plugin.Has(funcName) {
return p.Plugin.Call(funcName, arguments...)
}
commonName := fungo.ConvertCommonName(funcName)
if p.Plugin.Has(commonName) {
return p.Plugin.Call(commonName, arguments...)
}
}
// get builtin function
function, ok := builtin.Functions[funcName]
if !ok {
return nil, fmt.Errorf("function %s is not found", funcName)
}
fn := reflect.ValueOf(function)
// call with builtin function
return fungo.CallFunc(fn, arguments...)
}
// merge two variables mapping, the first variables have higher priority
func mergeVariables(variables, overriddenVariables map[string]interface{}) map[string]interface{} {
if overriddenVariables == nil {
return variables
}
if variables == nil {
return overriddenVariables
}
mergedVariables := make(map[string]interface{})
for k, v := range overriddenVariables {
mergedVariables[k] = v
}
for k, v := range variables {
if fmt.Sprintf("${%s}", k) == v || fmt.Sprintf("$%s", k) == v {
// e.g. {"base_url": "$base_url"}
// or {"base_url": "${base_url}"}
continue
}
mergedVariables[k] = v
}
return mergedVariables
}
// merge two map, the first map have higher priority
func mergeMap(m, overriddenMap map[string]string) map[string]string {
if overriddenMap == nil {
return m
}
if m == nil {
return overriddenMap
}
mergedMap := make(map[string]string)
for k, v := range overriddenMap {
mergedMap[k] = v
}
for k, v := range m {
mergedMap[k] = v
}
return mergedMap
}
// merge two validators slice, the first validators have higher priority
func mergeValidators(validators, overriddenValidators []interface{}) []interface{} {
if validators == nil {
return overriddenValidators
}
if overriddenValidators == nil {
return validators
}
var mergedValidators []interface{}
validators = append(validators, overriddenValidators...)
for _, validator := range validators {
flag := true
for _, mergedValidator := range mergedValidators {
if validator.(Validator).Check == mergedValidator.(Validator).Check {
flag = false
break
}
}
if flag {
mergedValidators = append(mergedValidators, validator)
}
}
return mergedValidators
}
// merge two slices, the first slice have higher priority
func mergeSlices(slice, overriddenSlice []string) []string {
if slice == nil {
return overriddenSlice
}
if overriddenSlice == nil {
return slice
}
for _, value := range overriddenSlice {
if !builtin.Contains(slice, value) {
slice = append(slice, value)
}
}
return slice
}
var eval = goval.NewEvaluator()
// literalEval parse string to number if possible
func literalEval(raw string) (interface{}, error) {
raw = strings.TrimSpace(raw)
// return raw string if not number
if !regexCompileNumber.Match([]byte(raw)) {
return raw, nil
}
// eval string to number
result, err := eval.Evaluate(raw, nil, nil)
if err != nil {
log.Error().Err(err).Msgf("[literalEval] eval %s failed", raw)
return raw, err
}
return result, nil
}
func parseFunctionArguments(argsStr string) ([]interface{}, error) {
argsStr = strings.TrimSpace(argsStr)
if argsStr == "" {
return []interface{}{}, nil
}
// split arguments by comma
args := strings.Split(argsStr, ",")
arguments := make([]interface{}, len(args))
for index, arg := range args {
arg = strings.TrimSpace(arg)
if arg == "" {
continue
}
// parse argument to number if possible
arg, err := literalEval(arg)
if err != nil {
return nil, err
}
arguments[index] = arg
}
return arguments, nil
}
func (p *Parser) ParseVariables(variables map[string]interface{}) (map[string]interface{}, error) {
parsedVariables := make(map[string]interface{})
var traverseRounds int
for len(parsedVariables) != len(variables) {
for varName, varValue := range variables {
// skip parsed variables
if _, ok := parsedVariables[varName]; ok {
continue
}
// extract variables from current value
extractVarsSet := extractVariables(varValue)
// check if reference variable itself
// e.g.
// variables = {"token": "abc$token"}
// variables = {"key": ["$key", 2]}
if _, ok := extractVarsSet[varName]; ok {
log.Error().Interface("variables", variables).Msg("[parseVariables] variable self reference error")
return variables, errors.Wrap(code.ParseVariablesError,
fmt.Sprintf("variable self reference: %v", varName))
}
// check if reference variable not in variables mapping
// e.g.
// {"varA": "123$varB", "varB": "456$varC"} => $varC not defined
// {"varC": "${sum_two($a, $b)}"} => $a, $b not defined
var undefinedVars []string
for extractVar := range extractVarsSet {
if _, ok := variables[extractVar]; !ok { // not in variables mapping
undefinedVars = append(undefinedVars, extractVar)
}
}
if len(undefinedVars) > 0 {
log.Error().Interface("undefinedVars", undefinedVars).Msg("[parseVariables] variable not defined error")
return variables, errors.Wrap(code.ParseVariablesError,
fmt.Sprintf("variable not defined: %v", undefinedVars))
}
parsedValue, err := p.Parse(varValue, parsedVariables)
if err != nil {
continue
}
parsedVariables[varName] = parsedValue
}
traverseRounds += 1
// check if circular reference exists
if traverseRounds > len(variables) {
log.Error().Msg("[parseVariables] circular reference error, break infinite loop!")
return variables, errors.Wrap(code.ParseVariablesError, "circular reference")
}
}
return parsedVariables, nil
}
type variableSet map[string]struct{}
func extractVariables(raw interface{}) variableSet {
rawValue := reflect.ValueOf(raw)
switch rawValue.Kind() {
case reflect.String:
return findallVariables(rawValue.String())
case reflect.Slice:
varSet := make(variableSet)
for i := 0; i < rawValue.Len(); i++ {
for extractVar := range extractVariables(rawValue.Index(i).Interface()) {
varSet[extractVar] = struct{}{}
}
}
return varSet
case reflect.Map:
varSet := make(variableSet)
for _, key := range rawValue.MapKeys() {
value := rawValue.MapIndex(key)
for extractVar := range extractVariables(value.Interface()) {
varSet[extractVar] = struct{}{}
}
}
return varSet
default:
// other types, e.g. nil, int, float, bool
return make(variableSet)
}
}
func findallVariables(raw string) variableSet {
matchStartPosition := 0
remainedString := raw
varSet := make(variableSet)
for matchStartPosition < len(raw) {
// locate $ char position
startPosition := strings.Index(remainedString, "$")
if startPosition == -1 { // no $ found
return varSet
}
// found $, check if variable or function
matchStartPosition += startPosition
remainedString = remainedString[startPosition:]
// Notice: notation priority
// $$ > $var
// search $$, use $$ to escape $ notation
if strings.HasPrefix(remainedString, "$$") { // found $$
matchStartPosition += 2
remainedString = remainedString[2:]
continue
}
// search variable like ${var} or $var
varMatched := regexCompileVariable.FindStringSubmatch(remainedString)
if len(varMatched) == 3 {
var varName string
if varMatched[1] != "" {
varName = varMatched[1] // match ${var}
} else {
varName = varMatched[2] // match $var
}
varSet[varName] = struct{}{}
matchStartPosition += len(varMatched[0])
remainedString = raw[matchStartPosition:]
continue
}
break
}
return varSet
}

View File

@@ -1,786 +0,0 @@
package hrp
import (
"net/url"
"sort"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestBuildURL(t *testing.T) {
var preparedURL *url.URL
preparedURL = buildURL("https://postman-echo.com", "/get", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/get/") {
t.Fatal()
}
preparedURL = buildURL("https://postman-echo.com", "get", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/get/") {
t.Fatal()
}
preparedURL = buildURL("https://postman-echo.com/", "/get", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/get/") {
t.Fatal()
}
preparedURL = buildURL("https://postman-echo.com/abc/", "/get?a=1&b=2", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/abc/get?a=1&b=2") {
t.Fatal()
}
preparedURL = buildURL("https://postman-echo.com/abc", "get?a=1&b=2", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/abc/get?a=1&b=2") {
t.Fatal()
}
// omit query string in base url
preparedURL = buildURL("https://postman-echo.com/abc?x=6&y=9", "/get?a=1&b=2", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/abc/get?a=1&b=2") {
t.Fatal()
}
preparedURL = buildURL("", "https://postman-echo.com/get", nil)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/get/") {
t.Fatal()
}
// notice: step request url > config base url
preparedURL = buildURL("https://postman-echo.com", "https://httpbin.org/get", nil)
if !assert.Equal(t, preparedURL.String(), "https://httpbin.org/get/") {
t.Fatal()
}
// websocket url
preparedURL = buildURL("wss://ws.postman-echo.com/raw", "", nil)
if !assert.Equal(t, preparedURL.String(), "wss://ws.postman-echo.com/raw/") {
t.Fatal()
}
preparedURL = buildURL("wss://ws.postman-echo.com", "/raw", nil)
if !assert.Equal(t, preparedURL.String(), "wss://ws.postman-echo.com/raw/") {
t.Fatal()
}
preparedURL = buildURL("wss://ws.postman-echo.com/raw", "ws://echo.websocket.events", nil)
if !assert.Equal(t, preparedURL.String(), "ws://echo.websocket.events/") {
t.Fatal()
}
queryParams := url.Values{}
queryParams.Add("c", "3")
queryParams.Add("d", "4")
preparedURL = buildURL("https://postman-echo.com/", "/get/", queryParams)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/get?c=3&d=4") {
t.Fatal()
}
preparedURL = buildURL("https://postman-echo.com/abc", "get?a=1&b=2", queryParams)
if !assert.Equal(t, preparedURL.String(), "https://postman-echo.com/abc/get?a=1&b=2&c=3&d=4") {
t.Fatal()
}
}
func TestRegexCompileVariable(t *testing.T) {
testData := []string{
"$var1",
"${var1}",
"$v",
"var_1$_v",
"${var_1}#XYZ",
"func1($var_1, $var_3)",
}
for _, expr := range testData {
varMatched := regexCompileVariable.FindStringSubmatch(expr)
if !assert.Len(t, varMatched, 3) {
t.Fatal()
}
}
}
func TestRegexCompileAbnormalVariable(t *testing.T) {
testData := []string{
"var1",
"${var1",
"$123",
"var_1$",
"func1($123, var_3)",
}
for _, expr := range testData {
varMatched := regexCompileVariable.FindStringSubmatch(expr)
if !assert.Len(t, varMatched, 0) {
t.Fatal()
}
}
}
func TestRegexCompileFunction(t *testing.T) {
testData := []string{
"${func1()}",
"${func1($a)}",
"${func1($a, $b)}",
"${func1($a, 123)}",
"${func1(123, $b)}",
"abc${func1(123, $b)}123",
}
for _, expr := range testData {
varMatched := regexCompileFunction.FindStringSubmatch(expr)
if !assert.Len(t, varMatched, 3) {
t.Fatal()
}
}
}
func TestRegexCompileAbnormalFunction(t *testing.T) {
testData := []string{
"${func1()",
"${func1(}",
"${func1)}",
"$func1()}",
"${1func1()}", // function name can not start with number
"${func1($a}",
"abc$func1(123, $b)}123",
// "${func1($a $b)}",
// "${func1($a, $123)}",
// "${func1(123 $b)}",
}
for _, expr := range testData {
varMatched := regexCompileFunction.FindStringSubmatch(expr)
if !assert.Len(t, varMatched, 0) {
t.Fatal()
}
}
}
func TestParseDataStringWithVariables(t *testing.T) {
variablesMapping := map[string]interface{}{
"var_1": "abc",
"var_2": "def",
"var_3": 123,
"var_4": map[string]interface{}{"a": 1},
"var_5": true,
"var_6": nil,
"v": 4.5, // variable name with one character
"_v": 6.9, // variable name starts with underscore
}
testData := []struct {
expr string
expect interface{}
}{
// no variable
{"var_1", "var_1"},
// single variable
{"$var_1", "abc"},
{"${var_1}", "abc"},
{"$var_3", 123},
{"$var_4", map[string]interface{}{"a": 1}},
{"${var_4}", map[string]interface{}{"a": 1}},
{"$var_5", true},
{"$var_6", nil},
{"$v", 4.5},
{"var_1$_v", "var_16.9"},
// single variable with prefix or suffix
{"$var_1#XYZ", "abc#XYZ"},
{"${var_1}#XYZ", "abc#XYZ"},
{"ABC$var_1", "ABCabc"},
{"ABC${var_1}", "ABCabc"},
{"ABC$var_1/", "ABCabc/"},
{"ABC${v}/", "ABC4.5/"},
// multiple variables
{"/$var_1/$var_2/var3", "/abc/def/var3"},
{"/${var_1}/$var_2/var3", "/abc/def/var3"},
{"ABC$var_1$var_3", "ABCabc123"},
{"ABC$var_1${var_3}", "ABCabc123"},
{"ABC$var_1/$var_3", "ABCabc/123"},
{"ABC${var_1}/${var_3}", "ABCabc/123"},
{"ABC$var_1/123$var_1/456", "ABCabc/123abc/456"},
{"ABC$var_1/123${var_1}/456", "ABCabc/123abc/456"},
{"ABC$var_1/$var_2/$var_1", "ABCabc/def/abc"},
{"ABC$var_1/$var_2/${var_1}", "ABCabc/def/abc"},
{"func1($var_1, $var_3)", "func1(abc, 123)"},
{"func1($var_1, ${var_3})", "func1(abc, 123)"},
// TODO: fix compatibility with python version
{"abc$var_4", "abcmap[a:1]"}, // "abc{'a': 1}"
{"abc$var_5", "abctrue"}, // "abcTrue"
}
parser := newParser()
for _, data := range testData {
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, parsedData) {
t.Fatal()
}
}
}
func TestParseDataStringWithUndefinedVariables(t *testing.T) {
variablesMapping := map[string]interface{}{
"var_1": "abc",
"var_2": "def",
}
testData := []struct {
expr string
expect interface{}
}{
{"/api/$SECRET_KEY", "/api/$SECRET_KEY"}, // raise error
}
parser := newParser()
for _, data := range testData {
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.Error(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, parsedData) {
t.Fatal()
}
}
}
func TestParseDataStringWithVariablesAbnormal(t *testing.T) {
variablesMapping := map[string]interface{}{
"var_1": "abc",
"var_2": "def",
"var_3": 123,
"var_4": map[string]interface{}{"a": 1},
"var_5": true,
"var_6": nil,
"v": 4.5, // variable name with one character
"_v": 6.9, // variable name starts with underscore
}
testData := []struct {
expr string
expect interface{}
}{
{"$", "$"},
{"var_1$", "var_1$"},
{"var_1$123", "var_1$123"}, // variable should starts with a letter
{"ABC$var_1{", "ABCabc{"}, // {
{"ABC$var_1}", "ABCabc}"}, // }
{"{ABC$var_1{}a}", "{ABCabc{}a}"}, // {xx}
{"AB{C$var_1{}a}", "AB{Cabc{}a}"}, // {xx{}x}
{"ABC$$var_1{", "ABC$var_1{"}, // $$
{"ABC$$$var_1{", "ABC$abc{"}, // $$$
{"ABC$$$$var_1{", "ABC$$var_1{"}, // $$$$
{"ABC$var_1${", "ABCabc${"}, // ${
{"ABC$var_1${a", "ABCabc${a"}, // ${
{"ABC$var_1$}a", "ABCabc$}a"}, // $}
{"ABC$var_1}{a", "ABCabc}{a"}, // }{
{"ABC$var_1{}a", "ABCabc{}a"}, // {}
}
parser := newParser()
for _, data := range testData {
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, parsedData) {
t.Fatal()
}
}
}
func TestParseDataMapWithVariables(t *testing.T) {
variablesMapping := map[string]interface{}{
"var1": "foo1",
"val1": 200,
"var2": 123, // key is int
}
testData := []struct {
expr map[string]interface{}
expect interface{}
}{
{map[string]interface{}{"key": "$var1"}, map[string]interface{}{"key": "foo1"}},
{map[string]interface{}{"foo1": "$val1", "foo2": "bar2"}, map[string]interface{}{"foo1": 200, "foo2": "bar2"}},
// parse map key, key is string
{map[string]interface{}{"$var1": "$val1"}, map[string]interface{}{"foo1": 200}},
// parse map key, key is int
{map[string]interface{}{"$var2": "$val1"}, map[string]interface{}{"123": 200}},
}
parser := newParser()
for _, data := range testData {
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, parsedData) {
t.Fatal()
}
}
}
func TestParseHeaders(t *testing.T) {
variablesMapping := map[string]interface{}{
"var1": "foo1",
"val1": 200,
"var2": 123, // key is int
"val2": nil, // value is nil
}
testData := []struct {
rawHeaders map[string]string
expectHeaders map[string]string
}{
{map[string]string{"key": "$var1"}, map[string]string{"key": "foo1"}},
{map[string]string{"foo1": "$val1", "foo2": "bar2"}, map[string]string{"foo1": "200", "foo2": "bar2"}},
// parse map key, key is string
{map[string]string{"$var1": "$val1"}, map[string]string{"foo1": "200"}},
// parse map key, key is int
{map[string]string{"$var2": "$val1"}, map[string]string{"123": "200"}},
// parse map key & value, key is int, value is nil
{map[string]string{"$var2": "$val2"}, map[string]string{"123": "<nil>"}},
}
parser := newParser()
for _, data := range testData {
parsedHeaders, err := parser.ParseHeaders(data.rawHeaders, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expectHeaders, parsedHeaders) {
t.Fatal()
}
}
}
func TestMergeVariables(t *testing.T) {
testData := []struct {
stepVariables map[string]interface{}
configVariables map[string]interface{}
expectVariables map[string]interface{}
}{
{
map[string]interface{}{"base_url": "$base_url", "foo1": "bar1"},
map[string]interface{}{"base_url": "https://httpbin.org", "foo1": "bar111"},
map[string]interface{}{"base_url": "https://httpbin.org", "foo1": "bar1"},
},
{
map[string]interface{}{"n": 3, "b": 34.5, "varFoo2": "${max($a, $b)}"},
map[string]interface{}{"n": 5, "a": 12.3, "b": 3.45, "varFoo1": "7a6K3", "varFoo2": 12.3},
map[string]interface{}{"n": 3, "a": 12.3, "b": 34.5, "varFoo1": "7a6K3", "varFoo2": "${max($a, $b)}"},
},
}
for _, data := range testData {
mergedVariables := mergeVariables(data.stepVariables, data.configVariables)
if !assert.Equal(t, data.expectVariables, mergedVariables) {
t.Fatal()
}
}
}
func TestMergeMap(t *testing.T) {
testData := []struct {
m map[string]string
overriddenMap map[string]string
expectMap map[string]string
}{
{
map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"},
map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"},
map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close", "Cache-Control": "no-cache"},
},
{
map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723"},
map[string]string{"Host": "Postman-echo.com", "Connection": "keep-alive", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b342c2723"},
map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723", "Connection": "keep-alive"},
},
{
map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"},
nil,
map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"},
},
{
nil,
map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"},
map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"},
},
}
for _, data := range testData {
mergedMap := mergeMap(data.m, data.overriddenMap)
if !assert.Equal(t, data.expectMap, mergedMap) {
t.Fatal()
}
}
}
func TestMergeSlices(t *testing.T) {
testData := []struct {
slice []string
overriddenSlice []string
expectSlice []string
}{
{
[]string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"},
[]string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"},
[]string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}", "${setup_hook_example3($name)}", "${setup_hook_example4($name)}"},
},
{
[]string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"},
nil,
[]string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"},
},
{
nil,
[]string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"},
[]string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"},
},
}
for _, data := range testData {
mergedSlice := mergeSlices(data.slice, data.overriddenSlice)
if !assert.Equal(t, data.expectSlice, mergedSlice) {
t.Fatal()
}
}
}
func TestMergeValidators(t *testing.T) {
testData := []struct {
validators []interface{}
overriddenValidators []interface{}
expectValidators []interface{}
}{
{
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}},
[]interface{}{Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"}},
[]interface{}{
Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"},
Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"},
},
},
{
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}},
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}},
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}},
},
{
nil,
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}},
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}},
},
{
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}},
nil,
[]interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}},
},
}
for _, data := range testData {
mergedValidators := mergeValidators(data.validators, data.overriddenValidators)
if !assert.Equal(t, data.expectValidators, mergedValidators) {
t.Fatal()
}
}
}
func TestCallBuiltinFunction(t *testing.T) {
parser := newParser()
// call function without arguments
_, err := parser.callFunc("get_timestamp")
if !assert.NoError(t, err) {
t.Fatal()
}
// call function with one argument
timeStart := time.Now()
_, err = parser.callFunc("sleep", 1)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Greater(t, time.Since(timeStart), time.Duration(1)*time.Second) {
t.Fatal()
}
// call function with one argument
result, err := parser.callFunc("gen_random_string", 10)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, 10, len(result.(string))) {
t.Fatal()
}
// call function with two argument
result, err = parser.callFunc("max", float64(10), 9.99)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, float64(10), result.(float64)) {
t.Fatal()
}
}
func TestLiteralEval(t *testing.T) {
testData := []struct {
expr string
expect interface{}
}{
{"123", 123},
{"1.23", 1.23},
{"-123", -123},
{"-1.23", -1.23},
{"abc", "abc"},
{" a bc ", "a bc"},
{" a $bc ", "a $bc"},
{"$var", "$var"},
{" $var ", "$var"},
{" $var1 ", "$var1"},
{"", ""},
}
for _, data := range testData {
value, err := literalEval(data.expr)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, value) {
t.Fatal()
}
}
}
func TestParseFunctionArguments(t *testing.T) {
testData := []struct {
expr string
expect interface{}
}{
{"", []interface{}{}},
{"123", []interface{}{123}},
{"1.23", []interface{}{1.23}},
{"-123", []interface{}{-123}},
{"-1.23", []interface{}{-1.23}},
{"abc", []interface{}{"abc"}},
{"$var", []interface{}{"$var"}},
{"1,2", []interface{}{1, 2}},
{"1,2.3", []interface{}{1, 2.3}},
{"1, -2.3", []interface{}{1, -2.3}},
{"1,,2", []interface{}{1, nil, 2}},
{" $var1 , 2 ", []interface{}{"$var1", 2}},
}
for _, data := range testData {
value, err := parseFunctionArguments(data.expr)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, value) {
t.Fatal()
}
}
}
func TestParseDataStringWithFunctions(t *testing.T) {
variablesMapping := map[string]interface{}{
"n": 5,
"a": 12.3,
"b": 3.45,
}
testData1 := []struct {
expr string
expect interface{}
}{
{"${gen_random_string(5)}", 5},
{"${gen_random_string($n)}", 5},
{"123${gen_random_string(5)}abc", 11},
{"123${gen_random_string($n)}abc", 11},
}
parser := newParser()
for _, data := range testData1 {
value, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, len(value.(string))) {
t.Fatal()
}
}
testData2 := []struct {
expr string
expect interface{}
}{
{"${max($a, $b)}", 12.3},
{"abc${max($a, $b)}123", "abc12.3123"},
{"abc${max($a, 3.45)}123", "abc12.3123"},
}
for _, data := range testData2 {
value, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expect, value) {
t.Fatal()
}
}
}
func TestConvertString(t *testing.T) {
testData := []struct {
raw interface{}
expect interface{}
}{
{"", ""},
{"abc", "abc"},
{"123", "123"},
{123, "123"},
{1.23, "1.23"},
{100000000000, "100000000000"}, // avoid exponential notation
{100000000000.23, "100000000000.23"},
{nil, "<nil>"},
}
for _, data := range testData {
value := convertString(data.raw)
if !assert.Equal(t, data.expect, value) {
t.Fatal()
}
}
}
func TestParseVariables(t *testing.T) {
testData := []struct {
rawVars map[string]interface{}
expectVars map[string]interface{}
}{
{
map[string]interface{}{"varA": "$varB", "varB": "$varC", "varC": "123", "a": 1, "b": 2},
map[string]interface{}{"varA": "123", "varB": "123", "varC": "123", "a": int64(1), "b": int64(2)},
},
{
map[string]interface{}{"n": 34.5, "a": 12.3, "b": "$n", "varFoo2": "${max($a, $b)}"},
map[string]interface{}{"n": 34.5, "a": 12.3, "b": 34.5, "varFoo2": 34.5},
},
}
parser := newParser()
for _, data := range testData {
value, err := parser.ParseVariables(data.rawVars)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expectVars, value) {
t.Fatal()
}
}
}
func TestParseVariablesAbnormal(t *testing.T) {
testData := []struct {
rawVars map[string]interface{}
expectVars map[string]interface{}
}{
{ // self referenced variable $varA
map[string]interface{}{"varA": "$varA"},
map[string]interface{}{"varA": "$varA"},
},
{ // undefined variable $varC
map[string]interface{}{"varA": "$varB", "varB": "$varC", "a": 1, "b": 2},
map[string]interface{}{"varA": "$varB", "varB": "$varC", "a": 1, "b": 2},
},
{ // circular reference
map[string]interface{}{"varA": "$varB", "varB": "$varA"},
map[string]interface{}{"varA": "$varB", "varB": "$varA"},
},
}
parser := newParser()
for _, data := range testData {
value, err := parser.ParseVariables(data.rawVars)
if !assert.Error(t, err) {
t.Fatal()
}
if !assert.Equal(t, data.expectVars, value) {
t.Fatal()
}
}
}
func TestExtractVariables(t *testing.T) {
testData := []struct {
raw interface{}
expectVars []string
}{
{nil, nil},
{"/$var1/$var1", []string{"var1"}},
{
map[string]interface{}{"varA": "$varB", "varB": "$varC", "varC": "123"},
[]string{"varB", "varC"},
},
{
[]interface{}{"varA", "$varB", 123, "$varC", "123"},
[]string{"varB", "varC"},
},
{ // nested map and slice
map[string]interface{}{"varA": "$varB", "varB": map[string]interface{}{"C": "$varC", "D": []string{"$varE"}}},
[]string{"varB", "varC", "varE"},
},
}
for _, data := range testData {
var varList []string
for varName := range extractVariables(data.raw) {
varList = append(varList, varName)
}
sort.Strings(varList)
if !assert.Equal(t, data.expectVars, varList) {
t.Fatal()
}
}
}
func TestFindallVariables(t *testing.T) {
testData := []struct {
raw string
expectVars []string
}{
{"", nil},
{"$variable", []string{"variable"}},
{"${variable}123", []string{"variable"}},
{"/blog/$postid", []string{"postid"}},
{"/$var1/$var2", []string{"var1", "var2"}},
{"/$var1/$var1", []string{"var1"}},
{"abc", nil},
{"Z:2>1*0*1+1$a", []string{"a"}},
{"Z:2>1*0*1+1$$a", nil},
{"Z:2>1*0*1+1$$$a", []string{"a"}},
{"Z:2>1*0*1+1$$$$a", nil},
{"Z:2>1*0*1+1$$a$b", []string{"b"}},
{"Z:2>1*0*1+1$$a$$b", nil},
{"Z:2>1*0*1+1$a$b", []string{"a", "b"}},
{"Z:2>1*0*1+1$$1", nil},
{"a$var", []string{"var"}},
{"a$v b", []string{"v"}},
{"${func()}", nil},
{"a${func(1,2)}b", nil},
{"${gen_md5($TOKEN, $data, $random)}", []string{"TOKEN", "data", "random"}},
}
for _, data := range testData {
var varList []string
for varName := range findallVariables(data.raw) {
varList = append(varList, varName)
}
sort.Strings(varList)
if !assert.Equal(t, data.expectVars, varList) {
t.Fatal()
}
}
}

View File

@@ -1,84 +0,0 @@
# hrp convert
## 快速上手
```shell
$ hrp convert -h
convert multiple source format to HttpRunner JSON/YAML/gotest/pytest cases
Usage:
hrp convert $path... [flags]
Flags:
--from-har load from HAR format
--from-json load from json case format (default true)
--from-postman load from postman format
--from-yaml load from yaml case format
-h, --help help for convert
-d, --output-dir string specify output directory
-p, --profile string specify profile path to override headers and cookies
--to-json convert to JSON case scripts (default true)
--to-pytest convert to pytest scripts
--to-yaml convert to YAML case scripts
Global Flags:
--log-json set log to json format
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 文件或 curl/Apache ab 指令转化为 HttpRunner JSON/YAML/gotest/pytest 形态的测试用例,同时也支持 HttpRunner 测试用例各个形态之间的相互转化。
该指令所有选项的详细说明如下:
- `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入转化为对应形态的 HttpRunner 测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例
- `--output-dir` 后接测试用例的期望输出目录的路径,用于将转换生成的测试用例输出到对应的文件夹;默认输出的文件夹为源文件所在的文件夹
- `--profile` 后接 profile 配置文件的路径,目前支持替换(不存在则会创建)或者覆盖输入的外部脚本/测试用例中的 `Headers``Cookies` 信息profile 文件的后缀可以为 `json/yaml/yml`,下面给出两类 profile 配置文件的示例:
- 根据 profile 替换指定的 `Headers``Cookies` 信息
```yaml
headers:
Header1: "this header will be created or updated"
cookies:
Cookie1: "this cookie will be created or updated"
```
- 根据 profile 覆盖原有的 `Headers``Cookies` 信息
```yaml
override: true
headers:
Header1: "all original headers will be overridden"
cookies:
Cookie1: "all original cookies will be overridden"
```
## 注意事项
1. 输出的测试用例文件名格式为 `源文件名称(不带拓展名)` + `_test` + `.json/.yaml/.go/.py 后缀`,如果该文件已经存在则会进行覆盖
2. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式
3. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格
## 转换流程图
`hrp convert` 的转换过程流程图如下:
![flow chart](asset/flowgram.png)
## 开发进度
`hrp convert` 当前的开发进度如下:
| from \ to | JSON | YAML | GoTest | PyTest |
|:---------:|:----:|:----:|:------:|:------:|
| HAR | ✅ | ✅ | ❌ | ✅ |
| Postman | ✅ | ✅ | ❌ | ✅ |
| JMeter | ❌ | ❌ | ❌ | ❌ |
| Swagger | ❌ | ❌ | ❌ | ❌ |
| curl | ✅ | ✅ | ❌ | ✅ |
| Apache ab | ❌ | ❌ | ❌ | ❌ |
| JSON | ✅ | ✅ | ❌ | ✅ |
| YAML | ✅ | ✅ | ❌ | ✅ |
| GoTest | ❌ | ❌ | ❌ | ❌ |
| PyTest | ❌ | ❌ | ❌ | ❌ |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,507 +0,0 @@
package convert
import (
"bufio"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/google/shlex"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/internal/json"
)
const (
originCmdKey = "_origin_cmd_key"
targetUrlKey = "_target_url_key"
)
var curlOptionAliasMap = map[string]string{
"-a": "--append",
"-A": "--user-agent",
"-b": "--cookie",
"-B": "--use-ascii",
"-c": "--cookie-jar",
"-C": "--continue-at",
"-d": "--data",
"-D": "--dump-header",
"-e": "--referer",
"-E": "--cert",
"-f": "--fail",
"-F": "--form",
"-g": "--globoff",
"-G": "--get",
"-h": "--help",
"-H": "--header",
"-i": "--include",
"-I": "--head",
"-j": "--junk-session-cookies",
"-J": "--remote-header-name",
"-k": "--insecure",
"-K": "--config",
"-l": "--list-only",
"-L": "--location",
"-m": "--max-time",
"-M": "--manual",
"-n": "--netrc",
"-N": "--no-buffer",
"-o": "--output",
"-O": "--remote-name",
"-p": "--proxytunnel",
"-P": "--ftp-port",
"-q": "--disable",
"-Q": "--quote",
"-r": "--range",
"-R": "--remote-time",
"-s": "--silent",
"-S": "--show-error",
"-t": "--telnet-option",
"-T": "--upload-file",
"-u": "--user",
"-U": "--proxy-user",
"-v": "--verbose",
"-V": "--version",
"-w": "--write-out",
"-x": "--proxy",
"-X": "--request",
"-Y": "--speed-limit",
"-y": "--speed-time",
"-z": "--time-cond",
"-Z": "--parallel",
}
var curlOptionWhiteMap = map[string]struct{}{
"--cookie": {},
"--data": {},
"--form": {},
"--get": {},
"--head": {},
"--header": {},
"--request": {},
}
var curlOptionWhiteList []string
func init() {
for option := range curlOptionWhiteMap {
curlOptionWhiteList = append(curlOptionWhiteList, option)
}
}
// LoadCurlCase loads testcase from one or more curl commands in .txt file
func LoadCurlCase(path string) (*hrp.TestCaseDef, error) {
cmds, err := readFileLines(path)
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: &hrp.TConfig{
Name: "testcase converted from curl command",
},
}
for _, cmd := range cmds {
tSteps, err := LoadCurlSteps(cmd)
if err != nil {
return nil, err
}
tCase.Steps = append(tCase.Steps, tSteps...)
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func readFileLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("open file failed")
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line == "\n" {
continue
}
lines = append(lines, line)
}
return lines, scanner.Err()
}
// LoadCurlSteps loads one teststep from one curl command
func LoadCurlSteps(cmd string) ([]*hrp.TStep, error) {
caseCurl, err := loadCaseCurl(cmd)
if err != nil {
return nil, err
}
return caseCurl.toTSteps()
}
func loadCaseCurl(cmd string) (CaseCurl, error) {
caseCurl := make(CaseCurl)
var err error
caseCurl, err = parseCaseCurl(cmd)
if err != nil {
return nil, errors.Wrap(err, "load curl command failed")
}
// deal with option alias, turn all options to long form
if err = caseCurl.toAlias(); err != nil {
return nil, errors.Wrap(err, "identify curl option alias failed")
}
// check if caseCurl contains unsupported args
if err = caseCurl.checkOptions(); err != nil {
return nil, errors.Wrap(err, "check curl option failed")
}
caseCurl.Set(originCmdKey, cmd)
return caseCurl, nil
}
// parseCaseCurl parses command string to map, save command keyword and bool option as map key only.
// Otherwise, save option as map key and the following args([]string) as map value
func parseCaseCurl(cmd string) (CaseCurl, error) {
cmdWords, err := shlex.Split(cmd)
if err != nil {
return nil, err
}
// parse the command string to map
res := make(CaseCurl)
var i int
if cmdWords[i] != "curl" {
return nil, errors.New("command not started with curl")
}
i++
for i < len(cmdWords) {
if !strings.HasPrefix(cmdWords[i], "-") {
// save target url
res.Add(targetUrlKey, cmdWords[i])
i++
continue
}
option := cmdWords[i]
i++
if i < len(cmdWords) && !strings.HasPrefix(cmdWords[i], "-") {
// option with only one following argument
res.Add(option, cmdWords[i])
i++
continue
}
// option with no argument, i.e. bool option, save key only
res[option] = nil
}
return res, nil
}
type CaseCurl map[string][]string
// Get gets the first value associated with the given key.
// If there are no values associated with the key, Get returns the empty string.
func (c CaseCurl) Get(key string, index int) string {
if c == nil {
return ""
}
vs := c[key]
if index >= 0 && index < len(vs) {
return vs[index]
}
return ""
}
func (c CaseCurl) Set(key, value string) {
c[key] = []string{value}
}
func (c CaseCurl) Add(key, value string) {
c[key] = append(c[key], value)
}
// HaveKey checks key existed or not
func (c CaseCurl) HaveKey(key string) bool {
if c == nil {
return false
}
_, ok := c[key]
return ok
}
func (c CaseCurl) toAlias() error {
for option, args := range c {
if !strings.HasPrefix(option, "-") || strings.HasPrefix(option, "--") {
// not a short option like -X, pass
continue
}
longOption, ok := curlOptionAliasMap[option]
if !ok {
return errors.Errorf("unexpected curl option: %v", option)
}
// FIXME: need to copy args or not?
c[longOption] = args
delete(c, option)
}
return nil
}
func (c CaseCurl) checkOptions() error {
for option := range c {
if option == originCmdKey || option == targetUrlKey {
continue
}
_, ok := curlOptionWhiteMap[option]
if !ok {
return errors.Errorf("option %v not supported yet. available options: %v", option, curlOptionWhiteList)
}
}
return nil
}
func (c CaseCurl) toTSteps() ([]*hrp.TStep, error) {
var tSteps []*hrp.TStep
for _, rawUrl := range c[targetUrlKey] {
log.Info().
Str("url", rawUrl).
Msg("convert test steps")
step := &stepFromCurl{
TStep: &hrp.TStep{
Request: &hrp.Request{},
},
}
if err := step.makeRequestName(c); err != nil {
return nil, err
}
if err := step.makeRequestMethod(c); err != nil {
return nil, err
}
if err := step.makeRequestURL(rawUrl); err != nil {
return nil, err
}
if err := step.makeRequestParams(rawUrl); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(c); err != nil {
return nil, err
}
if err := step.makeRequestCookies(c); err != nil {
return nil, err
}
if err := step.makeRequestBody(c); err != nil {
return nil, err
}
tSteps = append(tSteps, step.TStep)
}
return tSteps, nil
}
type stepFromCurl struct {
*hrp.TStep
}
func (s *stepFromCurl) makeRequestName(c CaseCurl) error {
s.StepName = c.Get(originCmdKey, 0)
return nil
}
func (s *stepFromCurl) makeRequestMethod(c CaseCurl) error {
// default --get
s.Request.Method = http.MethodGet
if c.HaveKey("--data") || c.HaveKey("--form") {
s.Request.Method = http.MethodPost
}
if c.HaveKey("--head") {
s.Request.Method = http.MethodHead
}
if c.HaveKey("--request") {
s.Request.Method = hrp.HTTPMethod(strings.ToUpper(c.Get("--request", 0)))
}
return nil
}
func (s *stepFromCurl) makeRequestURL(rawUrl string) error {
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
// default protocol consistent with curl (http)
if u.Scheme == "" {
u.Scheme = "http"
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromCurl) makeRequestParams(rawUrl string) error {
s.Request.Params = make(map[string]interface{})
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
s.Request.Params = make(map[string]interface{})
queryValues := u.Query()
// query key may correspond to more than one value, get first query key only
for k := range queryValues {
s.Request.Params[k] = queryValues.Get(k)
}
return nil
}
func (s *stepFromCurl) makeRequestHeaders(c CaseCurl) error {
s.Request.Headers = make(map[string]string)
headerList := c["--header"]
for _, headerExpr := range headerList {
if err := s.makeRequestHeader(headerExpr); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestHeader(headerExpr string) error {
headerExpr = strings.TrimSpace(headerExpr)
if strings.HasPrefix(headerExpr, "@") {
return errors.Errorf("loading header from file not supported: %v", headerExpr)
}
if strings.TrimSpace(headerExpr) == ";" || strings.HasPrefix(strings.TrimSpace(headerExpr), ":") {
return errors.Errorf("invalid curl header format: %v", headerExpr)
}
if s.Request.Headers == nil {
s.Request.Headers = make(map[string]string)
}
if i := strings.Index(headerExpr, ":"); i != -1 {
headerKey := strings.TrimSpace(headerExpr[:i])
var headerValue string
if i < len(headerExpr)-1 {
headerValue = strings.TrimSpace(headerExpr[i+1:])
}
if strings.ToLower(headerKey) == "host" {
// headerExpr modifying internal header like "Host:"
log.Warn().Str("--header", headerExpr).Msg("modifying internal header not supported")
return nil
}
if headerValue != "" {
// normal headerExpr like "User-Agent: httprunner"
s.Request.Headers[headerKey] = headerValue
return nil
}
}
if i := strings.Index(headerExpr, ";"); i != -1 {
// headerExpr terminated with a semicolon like "X-Custom-Header;"
headerKey := strings.TrimSpace(headerExpr[:i])
if strings.ToLower(headerKey) == "host" {
log.Warn().Str("--header", headerExpr).Msg("modifying internal header not supported")
return nil
}
s.Request.Headers[headerKey] = ""
return nil
}
log.Warn().Str("--header", headerExpr).Msg("pass meaningless curl header expression")
return nil
}
func (s *stepFromCurl) makeRequestCookies(c CaseCurl) error {
s.Request.Cookies = make(map[string]string)
cookieList := c["--cookie"]
for _, cookieExpr := range cookieList {
if err := s.makeRequestCookie(cookieExpr); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestCookie(cookieExpr string) error {
if !strings.Contains(cookieExpr, "=") {
return errors.Errorf("loading cookie from file not supported: %v", cookieExpr)
}
if s.Request.Cookies == nil {
s.Request.Cookies = make(map[string]string)
}
// deal with cookieExpr like "name1=value1; name2 = value2"
cookies := strings.Split(cookieExpr, ";")
for _, cookie := range cookies {
i := strings.Index(cookie, "=")
if i == -1 {
log.Warn().Str("--cookie", cookie).Msg("pass meaningless curl cookie expression")
continue
}
cookieKey := strings.TrimSpace(cookie[:i])
var cookieValue string
if i < len(cookie)-1 {
cookieValue = strings.TrimSpace(cookie[i+1:])
}
s.Request.Cookies[cookieKey] = cookieValue
}
return nil
}
func (s *stepFromCurl) makeRequestBody(c CaseCurl) error {
// check priority: --data > --form
dataList, dataExisted := c["--data"]
formList, formExisted := c["--form"]
if dataExisted {
if err := s.makeRequestData(dataList); err != nil {
return err
}
} else if formExisted {
if err := s.makeRequestForm(formList); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestData(dataList []string) error {
dataMap := make(map[string]interface{})
for _, dataExpr := range dataList {
if strings.HasPrefix(dataExpr, "@") {
return errors.Errorf("loading data from file not supported: %v", dataExpr)
}
var m map[string]interface{}
// --data may be json string, try to unmarshal to map first
err := json.Unmarshal([]byte(dataExpr), &m)
if err == nil {
for k, v := range m {
dataMap[k] = v
}
continue
}
dataValues, err := url.ParseQuery(dataExpr)
if err != nil {
return err
}
for dataKey := range dataValues {
dataMap[dataKey] = strings.Trim(dataValues.Get(dataKey), "\"'")
}
}
s.Request.Body = dataMap
return nil
}
func (s *stepFromCurl) makeRequestForm(formList []string) error {
if s.Request.Upload == nil {
s.Request.Upload = make(map[string]interface{})
}
for _, formExpr := range formList {
if !strings.Contains(formExpr, "=") {
return errors.Errorf("option --form: is badly used: %v", formExpr)
}
if i := strings.Index(formExpr, "="); i != -1 {
formKey := strings.TrimSpace(formExpr[:i])
var formValue string
if i < len(formExpr)-1 {
formValue = strings.TrimSpace(formExpr[i+1:])
}
s.Request.Upload[formKey] = strings.Trim(formValue, "\"")
}
}
return nil
}

View File

@@ -1,104 +0,0 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
)
var curlPath = "../../../examples/data/curl/curl_examples.txt"
func TestLoadCurlCase(t *testing.T) {
tCase, err := LoadCurlCase(curlPath)
if !assert.NoError(t, err) {
t.Fatal(err)
}
if !assert.Equal(t, 6, len(tCase.Steps)) {
t.Fatal()
}
// curl httpbin.org
if !assert.Equal(t, "curl httpbin.org", tCase.Steps[0].StepName) {
t.Fatal()
}
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, "http://httpbin.org", tCase.Steps[0].Request.URL) {
t.Fatal()
}
// curl https://httpbin.org/get?key1=value1&key2=value2
if !assert.Equal(t, "https://httpbin.org/get", tCase.Steps[1].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value1",
"key2": "value2",
}, tCase.Steps[1].Request.Params) {
t.Fatal()
}
// curl -H "Content-Type: application/json" \
// -H "Authorization: Bearer b7d03a6947b217efb6f3ec3bd3504582" \
// -d '{"type":"A","name":"www","data":"162.10.66.0","priority":null,"port":null,"weight":null}' \
// "https://httpbin.org/post"
if !assert.EqualValues(t, "POST", tCase.Steps[2].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Authorization": "Bearer b7d03a6947b217efb6f3ec3bd3504582",
"Content-Type": "application/json",
}, tCase.Steps[2].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"data": "162.10.66.0",
"name": "www",
"port": nil,
"priority": nil,
"type": "A",
"weight": nil,
}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
// curl -F "dummyName=dummyFile" -F file1=@file1.txt -F file2=@file2.txt https://httpbin.org/post
if !assert.Equal(t, map[string]interface{}{
"dummyName": "dummyFile",
"file1": "@file1.txt",
"file2": "@file2.txt",
}, tCase.Steps[3].Request.Upload) {
t.Fatal()
}
// curl https://httpbin.org/post \
// -d 'shipment[to_address][id]=adr_HrBKVA85' \
// -d 'shipment[from_address][id]=adr_VtuTOj7o' \
// -d 'shipment[parcel][id]=prcl_WDv2VzHp' \
// -d 'shipment[is_return]=true' \
// -d 'shipment[customs_info][id]=cstinfo_bl5sE20Y'
if !assert.Equal(t, map[string]interface{}{
"shipment[customs_info][id]": "cstinfo_bl5sE20Y",
"shipment[from_address][id]": "adr_VtuTOj7o",
"shipment[is_return]": "true",
"shipment[parcel][id]": "prcl_WDv2VzHp",
"shipment[to_address][id]": "adr_HrBKVA85",
}, tCase.Steps[4].Request.Body) {
t.Fatal()
}
// curl https://httpbing.org/post -H "Content-Type: application/x-www-form-urlencoded" \
// --data "key1=value+1&key2=value%3A2"
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
}, tCase.Steps[5].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value 1",
"key2": "value:2",
}, tCase.Steps[5].Request.Body) {
t.Fatal()
}
}

View File

@@ -1,19 +0,0 @@
package convert
import (
_ "embed"
"os"
"github.com/rs/zerolog/log"
)
func convert2GoTestScripts(paths ...string) error {
log.Warn().Msg("convert to gotest scripts is not supported yet")
os.Exit(1)
// format pytest scripts with black
return nil
}
//go:embed testcase.tmpl
var testcaseTemplate string

View File

@@ -1,624 +0,0 @@
package convert
import (
"encoding/base64"
"fmt"
"net/url"
"reflect"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/internal/json"
)
// ==================== model definition starts here ====================
/*
HTTP Archive (HAR) format
https://w3c.github.io/web-performance/specs/HAR/Overview.html
this file is copied from https://github.com/mrichman/hargo/blob/master/types.go
*/
// CaseHar is a container type for deserialization
type CaseHar struct {
Log Log `json:"log"`
}
// Log represents the root of the exported data. This object MUST be present and its name MUST be "log".
type Log struct {
// The object contains the following name/value pairs:
// Required. Version number of the format.
Version string `json:"version"`
// Required. An object of type creator that contains the name and version
// information of the log creator application.
Creator Creator `json:"creator"`
// Optional. An object of type browser that contains the name and version
// information of the user agent.
Browser Browser `json:"browser"`
// Optional. An array of objects of type page, each representing one exported
// (tracked) page. Leave out this field if the application does not support
// grouping by pages.
Pages []Page `json:"pages,omitempty"`
// Required. An array of objects of type entry, each representing one
// exported (tracked) HTTP request.
Entries []Entry `json:"entries"`
// Optional. A comment provided by the user or the application. Sorting
// entries by startedDateTime (starting from the oldest) is preferred way how
// to export data since it can make importing faster. However the reader
// application should always make sure the array is sorted (if required for
// the import).
Comment string `json:"comment"`
}
// Creator contains information about the log creator application
type Creator struct {
// Required. The name of the application that created the log.
Name string `json:"name"`
// Required. The version number of the application that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Browser that created the log
type Browser struct {
// Required. The name of the browser that created the log.
Name string `json:"name"`
// Required. The version number of the browser that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the browser.
Comment string `json:"comment"`
}
// Page object for every exported web page and one <entry> object for every HTTP request.
// In case when an HTTP trace tool isn't able to group requests by a page,
// the <pages> object is empty and individual requests doesn't have a parent page.
type Page struct {
/* There is one <page> object for every exported web page and one <entry>
object for every HTTP request. In case when an HTTP trace tool isn't able to
group requests by a page, the <pages> object is empty and individual
requests doesn't have a parent page.
*/
// Date and time stamp for the beginning of the page load
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00).
StartedDateTime string `json:"startedDateTime"`
// Unique identifier of a page within the . Entries use it to refer the parent page.
ID string `json:"id"`
// Page title.
Title string `json:"title"`
// Detailed timing info about page load.
PageTiming PageTiming `json:"pageTiming"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTiming describes timings for various events (states) fired during the page load.
// All times are specified in milliseconds. If a time info is not available appropriate field is set to -1.
type PageTiming struct {
// Content of the page loaded. Number of milliseconds since page load started
// (page.startedDateTime). Use -1 if the timing does not apply to the current
// request.
// Depeding on the browser, onContentLoad property represents DOMContentLoad
// event or document.readyState == interactive.
OnContentLoad int `json:"onContentLoad"`
// Page is loaded (onLoad event fired). Number of milliseconds since page
// load started (page.startedDateTime). Use -1 if the timing does not apply
// to the current request.
OnLoad int `json:"onLoad"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Entry is a unique, optional Reference to the parent page.
// Leave out this field if the application does not support grouping by pages.
type Entry struct {
Pageref string `json:"pageref,omitempty"`
// Date and time stamp of the request start
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD).
StartedDateTime string `json:"startedDateTime"`
// Total elapsed time of the request in milliseconds. This is the sum of all
// timings available in the timings object (i.e. not including -1 values) .
Time float32 `json:"time"`
// Detailed info about the request.
Request Request `json:"request"`
// Detailed info about the response.
Response Response `json:"response"`
// Info about cache usage.
Cache Cache `json:"cache"`
// Detailed timing info about request/response round trip.
PageTimings PageTimings `json:"pageTimings"`
// optional (new in 1.2) IP address of the server that was connected
// (result of DNS resolution).
ServerIPAddress string `json:"serverIPAddress,omitempty"`
// optional (new in 1.2) Unique ID of the parent TCP/IP connection, can be
// the client port number. Note that a port number doesn't have to be unique
// identifier in cases where the port is shared for more connections. If the
// port isn't available for the application, any other unique connection ID
// can be used instead (e.g. connection index). Leave out this field if the
// application doesn't support this info.
Connection string `json:"connection,omitempty"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Request contains detailed info about performed request.
type Request struct {
// Request method (GET, POST, ...).
Method string `json:"method"`
// Absolute URL of the request (fragments are not included).
URL string `json:"url"`
// Request HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// List of query parameter objects.
QueryString []NVP `json:"queryString"`
// Posted data.
PostData PostData `json:"postData"`
// Total number of bytes from the start of the HTTP request message until
// (and including) the double CRLF before the body. Set to -1 if the info
// is not available.
HeaderSize int `json:"headerSize"`
// Size of the request body (POST data payload) in bytes. Set to -1 if the
// info is not available.
BodySize int `json:"bodySize"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Response contains detailed info about the response.
type Response struct {
// Response status.
Status int `json:"status"`
// Response status description.
StatusText string `json:"statusText"`
// Response HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// Details about the response body.
Content Content `json:"content"`
// Redirection target URL from the Location response header.
RedirectURL string `json:"redirectURL"`
// Total number of bytes from the start of the HTTP response message until
// (and including) the double CRLF before the body. Set to -1 if the info is
// not available.
// The size of received response-headers is computed only from headers that
// are really received from the server. Additional headers appended by the
// browser are not included in this number, but they appear in the list of
// header objects.
HeadersSize int `json:"headersSize"`
// Size of the received response body in bytes. Set to zero in case of
// responses coming from the cache (304). Set to -1 if the info is not
// available.
BodySize int `json:"bodySize"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cookie contains list of all cookies (used in <request> and <response> objects).
type Cookie struct {
// The name of the cookie.
Name string `json:"name"`
// The cookie value.
Value string `json:"value"`
// optional The path pertaining to the cookie.
Path string `json:"path,omitempty"`
// optional The host of the cookie.
Domain string `json:"domain,omitempty"`
// optional Cookie expiration time.
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.123+02:00).
Expires string `json:"expires,omitempty"`
// optional Set to true if the cookie is HTTP only, false otherwise.
HTTPOnly bool `json:"httpOnly,omitempty"`
// optional (new in 1.2) True if the cookie was transmitted over ssl, false
// otherwise.
Secure bool `json:"secure,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment bool `json:"comment,omitempty"`
}
// NVP is simply a name/value pair with a comment
type NVP struct {
Name string `json:"name"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
// PostData describes posted data, if any (embedded in <request> object).
type PostData struct {
// Mime type of posted data.
MimeType string `json:"mimeType"`
// List of posted parameters (in case of URL encoded parameters).
Params []PostParam `json:"params"`
// Plain text posted data
Text string `json:"text"`
// optional (new in 1.2) A comment provided by the user or the
// application.
Comment string `json:"comment,omitempty"`
}
// PostParam is a list of posted parameters, if any (embedded in <postData> object).
type PostParam struct {
// name of a posted parameter.
Name string `json:"name"`
// optional value of a posted parameter or content of a posted file.
Value string `json:"value,omitempty"`
// optional name of a posted file.
FileName string `json:"fileName,omitempty"`
// optional content type of a posted file.
ContentType string `json:"contentType,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Content describes details about response content (embedded in <response> object).
type Content struct {
// Length of the returned content in bytes. Should be equal to
// response.bodySize if there is no compression and bigger when the content
// has been compressed.
Size int `json:"size"`
// optional Number of bytes saved. Leave out this field if the information
// is not available.
Compression int `json:"compression,omitempty"`
// MIME type of the response text (value of the Content-Type response
// header). The charset attribute of the MIME type is included (if
// available).
MimeType string `json:"mimeType"`
// optional Response body sent from the server or loaded from the browser
// cache. This field is populated with textual content only. The text field
// is either HTTP decoded text or a encoded (e.g. "base64") representation of
// the response body. Leave out this field if the information is not
// available.
Text string `json:"text,omitempty"`
// optional (new in 1.2) Encoding used for response text field e.g
// "base64". Leave out this field if the text field is HTTP decoded
// (decompressed & unchunked), than trans-coded from its original character
// set into UTF-8.
Encoding string `json:"encoding,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cache contains info about a request coming from browser cache.
type Cache struct {
// optional State of a cache entry before the request. Leave out this field
// if the information is not available.
BeforeRequest CacheObject `json:"beforeRequest,omitempty"`
// optional State of a cache entry after the request. Leave out this field if
// the information is not available.
AfterRequest CacheObject `json:"afterRequest,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// CacheObject is used by both beforeRequest and afterRequest
type CacheObject struct {
// optional - Expiration time of the cache entry.
Expires string `json:"expires,omitempty"`
// The last time the cache entry was opened.
LastAccess string `json:"lastAccess"`
// Etag
ETag string `json:"eTag"`
// The number of times the cache entry has been opened.
HitCount int `json:"hitCount"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTimings describes various phases within request-response round trip.
// All times are specified in milliseconds.
type PageTimings struct {
Blocked int `json:"blocked,omitempty"`
// optional - Time spent in a queue waiting for a network connection. Use -1
// if the timing does not apply to the current request.
DNS int `json:"dns,omitempty"`
// optional - DNS resolution time. The time required to resolve a host name.
// Use -1 if the timing does not apply to the current request.
Connect int `json:"connect,omitempty"`
// optional - Time required to create TCP connection. Use -1 if the timing
// does not apply to the current request.
Send int `json:"send"`
// Time required to send HTTP request to the server.
Wait int `json:"wait"`
// Waiting for a response from the server.
Receive int `json:"receive"`
// Time required to read entire response from the server (or cache).
Ssl int `json:"ssl,omitempty"`
// optional (new in 1.2) - Time required for SSL/TLS negotiation. If this
// field is defined then the time is also included in the connect field (to
// ensure backward compatibility with HAR 1.1). Use -1 if the timing does not
// apply to the current request.
Comment string `json:"comment,omitempty"`
// optional (new in 1.2) - A comment provided by the user or the application.
}
// TestResult contains results for an individual HTTP request
type TestResult struct {
URL string `json:"url"`
Status int `json:"status"` // 200, 500, etc.
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Latency int `json:"latency"` // milliseconds
Method string `json:"method"`
HarFile string `json:"harfile"`
}
// ==================== model definition ends here ====================
func LoadHARCase(path string) (*hrp.TestCaseDef, error) {
// load har file
caseHAR, err := loadCaseHAR(path)
if err != nil {
return nil, err
}
// convert to TestCase format
return caseHAR.ToTestCase()
}
func loadCaseHAR(path string) (*CaseHar, error) {
caseHAR := new(CaseHar)
err := hrp.LoadFileObject(path, caseHAR)
if err != nil {
return nil, errors.Wrap(err, "load har file failed")
}
if reflect.ValueOf(*caseHAR).IsZero() {
return nil, errors.New("invalid har file")
}
return caseHAR, nil
}
// convert CaseHar to TestCase format
func (c *CaseHar) ToTestCase() (*hrp.TestCaseDef, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: c.prepareConfig(),
Steps: teststeps,
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func (c *CaseHar) prepareConfig() *hrp.TConfig {
return hrp.NewConfig("testcase description").
SetVerifySSL(false)
}
func (c *CaseHar) prepareTestSteps() ([]*hrp.TStep, error) {
var steps []*hrp.TStep
for _, entry := range c.Log.Entries {
step, err := c.prepareTestStep(&entry)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func (c *CaseHar) prepareTestStep(entry *Entry) (*hrp.TStep, error) {
log.Info().
Str("method", entry.Request.Method).
Str("url", entry.Request.URL).
Msg("convert teststep")
step := &stepFromHAR{
TStep: hrp.TStep{
Request: &hrp.Request{},
StepConfig: hrp.StepConfig{
Validators: make([]interface{}, 0),
},
},
}
if err := step.makeRequestMethod(entry); err != nil {
return nil, err
}
if err := step.makeRequestURL(entry); err != nil {
return nil, err
}
if err := step.makeRequestParams(entry); err != nil {
return nil, err
}
if err := step.makeRequestCookies(entry); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(entry); err != nil {
return nil, err
}
if err := step.makeRequestBody(entry); err != nil {
return nil, err
}
if err := step.makeValidate(entry); err != nil {
return nil, err
}
return &step.TStep, nil
}
type stepFromHAR struct {
hrp.TStep
}
func (s *stepFromHAR) makeRequestMethod(entry *Entry) error {
s.Request.Method = hrp.HTTPMethod(entry.Request.Method)
return nil
}
func (s *stepFromHAR) makeRequestURL(entry *Entry) error {
u, err := url.Parse(entry.Request.URL)
if err != nil {
log.Error().Err(err).Msg("make request url failed")
return err
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromHAR) makeRequestParams(entry *Entry) error {
s.Request.Params = make(map[string]interface{})
for _, param := range entry.Request.QueryString {
s.Request.Params[param.Name] = param.Value
}
return nil
}
func (s *stepFromHAR) makeRequestCookies(entry *Entry) error {
// use cookies from har
s.Request.Cookies = make(map[string]string)
for _, cookie := range entry.Request.Cookies {
s.Request.Cookies[cookie.Name] = cookie.Value
}
return nil
}
func (s *stepFromHAR) makeRequestHeaders(entry *Entry) error {
// use headers from har
s.Request.Headers = make(map[string]string)
for _, header := range entry.Request.Headers {
if strings.EqualFold(header.Name, "cookie") {
continue
}
s.Request.Headers[header.Name] = header.Value
}
return nil
}
func (s *stepFromHAR) makeRequestBody(entry *Entry) error {
mimeType := entry.Request.PostData.MimeType
if mimeType == "" {
// GET/HEAD/DELETE without body
return nil
}
// POST/PUT with body
if strings.HasPrefix(mimeType, "application/json") {
// post json
var body interface{}
if entry.Request.PostData.Text == "" {
body = nil
} else {
err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body)
if err != nil {
log.Error().Err(err).Msg("make request body failed")
return err
}
}
s.Request.Body = body
} else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") {
// post form
paramsMap := make(map[string]string)
for _, param := range entry.Request.PostData.Params {
paramsMap[param.Name] = param.Value
}
s.Request.Body = paramsMap
} else if strings.HasPrefix(mimeType, "text/plain") {
// post raw data
s.Request.Body = entry.Request.PostData.Text
} else {
// TODO
log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType)
}
return nil
}
func (s *stepFromHAR) makeValidate(entry *Entry) error {
// make validator for response status code
s.Validators = append(s.Validators, hrp.Validator{
Check: "status_code",
Assert: "equals",
Expect: entry.Response.Status,
Message: "assert response status code",
})
// make validators for response headers
for _, header := range entry.Response.Headers {
// assert Content-Type
if strings.EqualFold(header.Name, "Content-Type") {
s.Validators = append(s.Validators, hrp.Validator{
Check: "headers.\"Content-Type\"",
Assert: "equals",
Expect: header.Value,
Message: "assert response header Content-Type",
})
}
}
// make validators for response body
respBody := entry.Response.Content
if respBody.Text == "" {
// response body is empty
return nil
}
if strings.HasPrefix(respBody.MimeType, "application/json") {
var data []byte
var err error
// response body is json
if respBody.Encoding == "base64" {
// decode base64 text
data, err = base64.StdEncoding.DecodeString(respBody.Text)
if err != nil {
return errors.Wrap(err, "decode base64 error")
}
} else if respBody.Encoding == "" {
// no encoding
data = []byte(respBody.Text)
} else {
// other encoding type
return nil
}
// convert to json
var body interface{}
if err = json.Unmarshal(data, &body); err != nil {
return errors.Wrap(err, "json.Unmarshal body error")
}
jsonBody, ok := body.(map[string]interface{})
if !ok {
return fmt.Errorf("response body is not json, not matched with MimeType")
}
// response body is json
keys := make([]string, 0, len(jsonBody))
for k := range jsonBody {
keys = append(keys, k)
}
// sort map keys to keep validators in stable order
sort.Strings(keys)
for _, key := range keys {
value := jsonBody[key]
switch v := value.(type) {
case map[string]interface{}:
continue
case []interface{}:
continue
default:
s.Validators = append(s.Validators, hrp.Validator{
Check: fmt.Sprintf("body.%s", key),
Assert: "equals",
Expect: v,
Message: fmt.Sprintf("assert response body %s", key),
})
}
}
}
return nil
}

View File

@@ -1,282 +0,0 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/httprunner/httprunner/v5/hrp"
)
var harPath = "../../../examples/data/har/demo.har"
var caseHar *CaseHar
func init() {
caseHar, _ = loadCaseHAR(harPath)
}
func TestLoadHAR(t *testing.T) {
caseHAR, err := loadCaseHAR(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, "GET", caseHAR.Log.Entries[0].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, "POST", caseHAR.Log.Entries[1].Request.Method) {
t.Fatal()
}
}
func TestLoadTCaseFromHAR(t *testing.T) {
tCase, err := LoadHARCase(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
// make request method
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.Steps[1].Request.Method) {
t.Fatal()
}
// make request url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.Steps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.Steps[1].Request.URL) {
t.Fatal()
}
// make request params
if !assert.Equal(t, "HDnY8", tCase.Steps[0].Request.Params["foo1"]) {
t.Fatal()
}
// make request cookies
if !assert.NotEmpty(t, tCase.Steps[1].Request.Cookies["sails.sid"]) {
t.Fatal()
}
// make request headers
if !assert.Equal(t, "HttpRunnerPlus", tCase.Steps[0].Request.Headers["User-Agent"]) {
t.Fatal()
}
if !assert.Equal(t, "postman-echo.com", tCase.Steps[0].Request.Headers["Host"]) {
t.Fatal()
}
// make request data
if !assert.Equal(t, nil, tCase.Steps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.Steps[1].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"foo1": "HDnY8", "foo2": "12.3"}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
// make validators
validator, ok := tCase.Steps[0].Validators[0].(hrp.Validator)
if !ok || !assert.Equal(t, "status_code", validator.Check) {
t.Fatal()
}
validator, ok = tCase.Steps[0].Validators[1].(hrp.Validator)
if !ok || !assert.Equal(t, "headers.\"Content-Type\"", validator.Check) {
t.Fatal()
}
validator, ok = tCase.Steps[0].Validators[2].(hrp.Validator)
if !ok || !assert.Equal(t, "body.url", validator.Check) {
t.Fatal()
}
}
func TestMakeRequestURL(t *testing.T) {
entry := &Entry{
Request: Request{
URL: "http://127.0.0.1:8080/api/login",
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, "http://127.0.0.1:8080/api/login", step.Request.URL) {
t.Fatal()
}
}
func TestMakeRequestHeaders(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
Headers: []NVP{
{Name: "Content-Type", Value: "application/json; charset=utf-8"},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/json; charset=utf-8",
}, step.Request.Headers) {
t.Fatal()
}
}
func TestMakeRequestCookies(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
Cookies: []Cookie{
{Name: "abc", Value: "123"},
{Name: "UserName", Value: "leolee"},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"abc": "123",
"UserName": "leolee",
}, step.Request.Cookies) {
t.Fatal()
}
}
func TestMakeRequestDataParams(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/x-www-form-urlencoded; charset=utf-8",
Params: []PostParam{
{Name: "a", Value: "1"},
{Name: "b", Value: "2"},
},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"a": "1", "b": "2"}, step.Request.Body) {
t.Fatal()
}
}
func TestMakeRequestDataJSON(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/json; charset=utf-8",
Text: "{\"a\":\"1\",\"b\":\"2\"}",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"a": "1", "b": "2"}, step.Request.Body) {
t.Fatal()
}
}
func TestMakeRequestDataTextEmpty(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/json; charset=utf-8",
Text: "",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, nil, step.Request.Body) { // TODO
t.Fatal()
}
}
func TestMakeValidate(t *testing.T) {
entry := &Entry{
Response: Response{
Status: 200,
Headers: []NVP{
{Name: "Content-Type", Value: "application/json; charset=utf-8"},
},
Content: Content{
Size: 71,
MimeType: "application/json; charset=utf-8",
// map[Code:200 IsSuccess:true Message:<nil> Value:map[BlnResult:true]]
Text: "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=",
Encoding: "base64",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
validator, ok := step.Validators[0].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "status_code",
Expect: 200,
Assert: "equals",
Message: "assert response status code",
}) {
t.Fatal()
}
validator, ok = step.Validators[1].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "headers.\"Content-Type\"",
Expect: "application/json; charset=utf-8",
Assert: "equals",
Message: "assert response header Content-Type",
}) {
t.Fatal()
}
validator, ok = step.Validators[2].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "body.Code",
Expect: float64(200), // TODO
Assert: "equals",
Message: "assert response body Code",
}) {
t.Fatal()
}
}

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,27 +0,0 @@
package convert
import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp"
)
func LoadJSONCase(path string) (*hrp.TestCaseDef, error) {
log.Info().Str("path", path).Msg("load json case file")
caseJSON := new(hrp.TestCaseDef)
err := hrp.LoadFileObject(path, caseJSON)
if err != nil {
return nil, errors.Wrap(err, "load json file failed")
}
if caseJSON.Steps == nil {
return nil, errors.New("invalid json case file, missing teststeps")
}
err = hrp.ConvertCaseCompatibility(caseJSON)
if err != nil {
return nil, err
}
return caseJSON, nil
}

View File

@@ -1,394 +0,0 @@
package convert
import (
"fmt"
"net/url"
"reflect"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/hrp"
"github.com/httprunner/httprunner/v5/hrp/internal/json"
)
// ==================== model definition starts here ====================
/*
Postman Collection format reference:
https://schema.postman.com/json/collection/v2.0.0/collection.json
https://schema.postman.com/json/collection/v2.1.0/collection.json
*/
// CasePostman represents the postman exported file
type CasePostman struct {
Info TInfo `json:"info"`
Items []TItem `json:"item"`
}
// TInfo gives information about the collection
type TInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Schema string `json:"schema"`
}
// TItem contains the detail information of request and expected responses
// item could be defined recursively
type TItem struct {
Items []TItem `json:"item"`
Name string `json:"name"`
Request TRequest `json:"request"`
Responses []TResponse `json:"response"`
}
type TRequest struct {
Method string `json:"method"`
Headers []TField `json:"header"`
Body TBody `json:"body"`
URL TUrl `json:"url"`
Description string `json:"description"`
}
type TResponse struct {
Name string `json:"name"`
OriginalRequest TRequest `json:"originalRequest"`
Status string `json:"status"`
Code int `json:"code"`
Headers []TField `json:"headers"`
Body string `json:"body"`
}
type TUrl struct {
Raw string `json:"raw"`
Protocol string `json:"protocol"`
Path []string `json:"path"`
Description string `json:"description"`
Query []TField `json:"query"`
Variable []TField `json:"variable"`
}
type TField struct {
Key string `json:"key"`
Value string `json:"value"`
Src string `json:"src"`
Description string `json:"description"`
Type string `json:"type"`
Disabled bool `json:"disabled"`
Enable bool `json:"enable"`
}
type TBody struct {
Mode string `json:"mode"`
FormData []TField `json:"formdata"`
URLEncoded []TField `json:"urlencoded"`
Raw string `json:"raw"`
Disabled bool `json:"disabled"`
Options interface{} `json:"options"`
}
// ==================== model definition ends here ====================
const (
enumBodyRaw = "raw"
enumBodyUrlEncoded = "urlencoded"
enumBodyFormData = "formdata"
enumBodyFile = "file"
enumBodyGraphQL = "graphql"
)
const (
enumFieldTypeText = "text"
enumFieldTypeFile = "file"
)
var contentTypeMap = map[string]string{
"text": "text/plain",
"javascript": "application/javascript",
"json": "application/json",
"html": "text/html",
"xml": "application/xml",
}
func LoadPostmanCase(path string) (*hrp.TestCaseDef, error) {
log.Info().Str("path", path).Msg("load postman case file")
casePostman, err := loadCasePostman(path)
if err != nil {
return nil, err
}
// convert to TestCase format
return casePostman.ToTestCase()
}
func loadCasePostman(path string) (*CasePostman, error) {
casePostman := new(CasePostman)
err := hrp.LoadFileObject(path, casePostman)
if err != nil {
return nil, errors.Wrap(err, "load postman file failed")
}
if casePostman.Items == nil {
return nil, errors.New("invalid postman case file, missing items")
}
return casePostman, nil
}
func (c *CasePostman) ToTestCase() (*hrp.TestCaseDef, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: c.prepareConfig(),
Steps: teststeps,
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func (c *CasePostman) prepareConfig() *hrp.TConfig {
return hrp.NewConfig(c.Info.Name).
SetVerifySSL(false)
}
func (c *CasePostman) prepareTestSteps() ([]*hrp.TStep, error) {
// recursively convert collection items into a list
var itemList []TItem
for _, item := range c.Items {
extractItemList(item, &itemList)
}
var steps []*hrp.TStep
for _, item := range itemList {
step, err := c.prepareTestStep(&item)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func extractItemList(item TItem, itemList *[]TItem) {
// current item contains no other items and request is not empty
if len(item.Items) == 0 {
if !reflect.DeepEqual(item.Request, TRequest{}) {
*itemList = append(*itemList, item)
}
return
}
// look up all items inside
for _, i := range item.Items {
// append item name
i.Name = fmt.Sprintf("%s - %s", item.Name, i.Name)
extractItemList(i, itemList)
}
}
func (c *CasePostman) prepareTestStep(item *TItem) (*hrp.TStep, error) {
log.Info().
Str("method", item.Request.Method).
Str("url", item.Request.URL.Raw).
Msg("convert teststep")
step := &stepFromPostman{
TStep: hrp.TStep{
Request: &hrp.Request{},
StepConfig: hrp.StepConfig{
Validators: make([]interface{}, 0),
},
},
}
if err := step.makeRequestName(item); err != nil {
return nil, err
}
if err := step.makeRequestMethod(item); err != nil {
return nil, err
}
if err := step.makeRequestURL(item); err != nil {
return nil, err
}
if err := step.makeRequestParams(item); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(item); err != nil {
return nil, err
}
if err := step.makeRequestCookies(item); err != nil {
return nil, err
}
if err := step.makeRequestBody(item); err != nil {
return nil, err
}
return &step.TStep, nil
}
type stepFromPostman struct {
hrp.TStep
}
// makeRequestName indicates the step name the same as item name
func (s *stepFromPostman) makeRequestName(item *TItem) error {
s.StepName = item.Name
return nil
}
func (s *stepFromPostman) makeRequestMethod(item *TItem) error {
s.Request.Method = hrp.HTTPMethod(item.Request.Method)
return nil
}
func (s *stepFromPostman) makeRequestURL(item *TItem) error {
rawUrl := item.Request.URL.Raw
// parse path variables like ":path" in https://postman-echo.com/:path?k1=v1&k2=v2
for _, field := range item.Request.URL.Variable {
pathVar := ":" + field.Key
rawUrl = strings.Replace(rawUrl, pathVar, field.Value, -1)
}
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromPostman) makeRequestParams(item *TItem) error {
s.Request.Params = make(map[string]interface{})
for _, field := range item.Request.URL.Query {
if field.Disabled {
continue
}
s.Request.Params[field.Key] = field.Value
}
return nil
}
func (s *stepFromPostman) makeRequestHeaders(item *TItem) error {
// headers defined in postman collection
s.Request.Headers = make(map[string]string)
for _, field := range item.Request.Headers {
if field.Disabled || strings.EqualFold(field.Key, "cookie") {
continue
}
s.Request.Headers[field.Key] = field.Value
}
return nil
}
func (s *stepFromPostman) makeRequestCookies(item *TItem) error {
// cookies defined in postman collection
s.Request.Cookies = make(map[string]string)
for _, field := range item.Request.Headers {
if field.Disabled || !strings.EqualFold(field.Key, "cookie") {
continue
}
s.parseRequestCookiesMap(field.Value)
}
return nil
}
func (s *stepFromPostman) parseRequestCookiesMap(cookies string) {
for _, cookie := range strings.Split(cookies, ";") {
cookie = strings.TrimSpace(cookie)
index := strings.Index(cookie, "=")
if index == -1 {
log.Warn().Str("cookie", cookie).Msg("cookie format invalid")
continue
}
s.Request.Cookies[cookie[:index]] = cookie[index+1:]
}
}
func (s *stepFromPostman) makeRequestBody(item *TItem) error {
mode := item.Request.Body.Mode
if mode == "" {
return nil
}
switch mode {
case enumBodyRaw:
return s.makeRequestBodyRaw(item)
case enumBodyFormData:
return s.makeRequestBodyFormData(item)
case enumBodyUrlEncoded:
return s.makeRequestBodyUrlEncoded(item)
case enumBodyFile, enumBodyGraphQL:
return errors.Errorf("unsupported body type: %v", mode)
}
return nil
}
func (s *stepFromPostman) makeRequestBodyRaw(item *TItem) (err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("make request body (raw) failed: %v", p)
}
}()
languageType := "text"
iOptions := item.Request.Body.Options
if iOptions != nil {
iLanguage := iOptions.(map[string]interface{})["raw"]
if iLanguage != nil {
languageType = iLanguage.(map[string]interface{})["language"].(string)
}
}
s.Request.Body = item.Request.Body.Raw
contentType := s.Request.Headers["Content-Type"]
if strings.Contains(contentType, "application/json") || languageType == "json" {
var iBody interface{}
err = json.Unmarshal([]byte(item.Request.Body.Raw), &iBody)
if err != nil {
return errors.Wrap(err, "make request body (raw -> json) failed")
}
s.Request.Body = iBody
}
if contentType == "" {
s.Request.Headers["Content-Type"] = contentTypeMap[languageType]
}
return
}
func (s *stepFromPostman) makeRequestBodyFormData(item *TItem) error {
s.Request.Upload = make(map[string]interface{})
for _, field := range item.Request.Body.FormData {
if field.Disabled {
continue
}
// form data could be text or file
if field.Type == enumFieldTypeText {
s.Request.Upload[field.Key] = field.Value
} else if field.Type == enumFieldTypeFile {
s.Request.Upload[field.Key] = field.Src
} else {
return errors.Errorf("make request body form data failed: unexpect field type: %v", field.Type)
}
}
return nil
}
func (s *stepFromPostman) makeRequestBodyUrlEncoded(item *TItem) error {
payloadMap := make(map[string]string)
for _, field := range item.Request.Body.URLEncoded {
if field.Disabled {
continue
}
payloadMap[field.Key] = field.Value
}
s.Request.Body = payloadMap
s.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"
return nil
}
// TODO makeValidate from test scripts
func (s *stepFromPostman) makeValidate(item *TItem) error {
return nil
}

View File

@@ -1,78 +0,0 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
)
var collectionPath = "../../../examples/data/postman/postman_collection.json"
func TestLoadCollection(t *testing.T) {
casePostman, err := loadCasePostman(collectionPath)
if !assert.NoError(t, err) {
t.Fatal(err)
}
if !assert.Equal(t, "postman collection demo", casePostman.Info.Name) {
t.Fatal()
}
}
func TestMakeTestCaseFromCollection(t *testing.T) {
tCase, err := LoadPostmanCase(collectionPath)
if !assert.NoError(t, err) {
t.Fatal()
}
// check name
if !assert.Equal(t, "postman collection demo", tCase.Config.Name) {
t.Fatal()
}
// check method
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.Steps[1].Request.Method) {
t.Fatal()
}
// check url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.Steps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.Steps[1].Request.URL) {
t.Fatal()
}
// check params
if !assert.Equal(t, "v1", tCase.Steps[0].Request.Params["k1"]) {
t.Fatal()
}
// check cookies (pass, postman collection doesn't contain cookies)
// check headers
if !assert.Equal(t, "application/x-www-form-urlencoded", tCase.Steps[2].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "application/json", tCase.Steps[3].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "text/plain", tCase.Steps[4].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "HttpRunner", tCase.Steps[5].Request.Headers["User-Agent"]) {
t.Fatal()
}
// check body
if !assert.Equal(t, nil, tCase.Steps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"k1": "v1", "k2": "v2"}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"k1": "v1", "k2": "v2"}, tCase.Steps[3].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, "have a nice day", tCase.Steps[4].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, nil, tCase.Steps[5].Request.Body) {
t.Fatal()
}
}

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,23 +0,0 @@
package convert
import (
"github.com/go-openapi/spec"
"github.com/pkg/errors"
"github.com/httprunner/httprunner/v5/hrp"
)
func LoadSwaggerCase(path string) (*hrp.TestCaseDef, error) {
// load swagger file
caseSwagger := new(spec.Swagger)
err := hrp.LoadFileObject(path, caseSwagger)
if err != nil {
return nil, errors.Wrap(err, "load swagger file failed")
}
if caseSwagger.Definitions == nil {
return nil, errors.New("invalid swagger case file, missing definitions")
}
// TODO: convert swagger to TCase
return nil, nil
}

View File

@@ -1,27 +0,0 @@
package convert
import (
"reflect"
"github.com/pkg/errors"
"github.com/httprunner/httprunner/v5/hrp"
)
func LoadYAMLCase(path string) (*hrp.TestCaseDef, error) {
// load yaml case file
caseJSON := new(hrp.TestCaseDef)
err := hrp.LoadFileObject(path, caseJSON)
if err != nil {
return nil, errors.Wrap(err, "load yaml file failed")
}
if reflect.ValueOf(*caseJSON).IsZero() {
return nil, errors.New("invalid yaml file")
}
err = hrp.ConvertCaseCompatibility(caseJSON)
if err != nil {
return nil, err
}
return caseJSON, nil
}

Some files were not shown because too many files have changed in this diff Show More