refactor: move converter from hrp internal to pkg

This commit is contained in:
debugtalk
2022-10-11 11:22:02 +08:00
parent 0a0700dda8
commit 0ff5fb762f
20 changed files with 41 additions and 3 deletions

81
hrp/pkg/convert/README.md Normal file
View File

@@ -0,0 +1,81 @@
# hrp convert
## 快速上手
```shell
$ hrp convert -h
convert to JSON/YAML/gotest/pytest testcases
Usage:
hrp convert $path... [flags]
Flags:
-h, --help help for convert
-d, --output-dir string specify output directory, default to the same dir with har file
-p, --profile string specify profile path to override headers (except for auto-generated headers) and cookies
--to-gotest convert to gotest scripts (TODO)
--to-json convert to JSON scripts (default true)
--to-pytest convert to pytest scripts
--to-yaml convert to YAML scripts
Global Flags:
--log-json set log to json format
-l, --log-level string set log level (default "INFO")
```
`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 文件或 curl/Apache ab 指令转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化。
该指令所有选项的详细说明如下:
1. `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入转化为对应形态的测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例
2. `--output-dir` 后接测试用例的期望输出目录的路径,用于将转换生成的测试用例输出到对应的文件夹
3. `--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. 输出的测试用例文件名格式为 `Postman 工程文件名称(不带拓展名)` + `_test` + `.json/.yaml/.go/.py 后缀`,如果该文件已经存在则会进行覆盖
2. `hrp convert` 可以自动识别输入类型,因此不需要通过选项来手动制定输入类型,如遇到无法识别、不支持或转换失败的情况,则会输出错误日志并跳过,不会影响其他转换过程的正常进行
3. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式
4. 输入为 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.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,279 @@
package convert
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
)
// target testcase format extensions
const (
suffixJSON = ".json"
suffixYAML = ".yaml"
suffixGoTest = ".go"
suffixPyTest = ".py"
)
type OutputType int
const (
OutputTypeJSON OutputType = iota // default output type: JSON
OutputTypeYAML
OutputTypeGoTest
OutputTypePyTest
)
func (outputType OutputType) String() string {
switch outputType {
case OutputTypeYAML:
return "yaml"
case OutputTypeGoTest:
return "gotest"
case OutputTypePyTest:
return "pytest"
default:
return "json"
}
}
// Profile is used to override or update(create if not existed) original headers and cookies
type Profile struct {
Override bool `json:"override" yaml:"override"`
Headers map[string]string `json:"headers" yaml:"headers"`
Cookies map[string]string `json:"cookies" yaml:"cookies"`
}
func Run(outputType OutputType, outputDir, profilePath string, args []string) {
// report event
sdk.SendEvent(sdk.EventTracking{
Category: "ConvertTests",
Action: fmt.Sprintf("hrp convert --to-%s", outputType.String()),
})
var outputFiles []string
for _, inputSample := range args {
// loads source file and convert to TCase format
tCase, err := LoadTCase(inputSample)
if err != nil {
log.Warn().Err(err).Str("input sample", inputSample).Msg("convert input sample failed")
continue
}
caseConverter := &TCaseConverter{
InputSample: inputSample,
OutputDir: outputDir,
TCase: tCase,
}
// override TCase with profile
if profilePath != "" {
caseConverter.overrideWithProfile(profilePath)
}
// convert TCase format to target case format
var outputFile string
switch outputType {
case OutputTypeYAML:
outputFile, err = caseConverter.ToYAML()
case OutputTypeGoTest:
outputFile, err = caseConverter.ToGoTest()
case OutputTypePyTest:
outputFile, err = caseConverter.ToPyTest()
default:
outputFile, err = caseConverter.ToJSON()
}
if err != nil {
log.Error().Err(err).
Str("input sample", caseConverter.InputSample).
Msg("convert case failed")
continue
}
outputFiles = append(outputFiles, outputFile)
}
log.Info().Strs("output files", outputFiles).Msg("conversion completed")
}
// LoadTCase loads source file and convert to TCase type
func LoadTCase(inputSample string) (*hrp.TCase, error) {
if strings.HasPrefix(inputSample, "curl ") {
// 'path' contains curl command
curlCase, err := LoadSingleCurlCase(inputSample)
if err != nil {
return nil, err
}
return curlCase, nil
}
extName := filepath.Ext(inputSample)
if extName == "" {
return nil, errors.New("file extension is not specified")
}
switch extName {
case ".har":
tCase, err := LoadHARCase(inputSample)
if err != nil {
return nil, err
}
return tCase, nil
case ".json":
// priority: hrp JSON case > postman > swagger
// check if hrp JSON case
tCase, err := LoadJSONCase(inputSample)
if err == nil {
return tCase, nil
}
// check if postman format
casePostman, err := LoadPostmanCase(inputSample)
if err == nil {
return casePostman, nil
}
// check if swagger format
caseSwagger, err := LoadSwaggerCase(inputSample)
if err == nil {
return caseSwagger, nil
}
return nil, errors.New("unexpected JSON format")
case ".yaml", ".yml":
// priority: hrp YAML case > swagger
// check if hrp YAML case
tCase, err := NewYAMLCase(inputSample)
if err == nil {
return tCase, nil
}
// check if swagger format
caseSwagger, err := LoadSwaggerCase(inputSample)
if err == nil {
return caseSwagger, nil
}
return nil, errors.New("unexpected YAML format")
case ".go": // TODO
return nil, errors.New("convert gotest is not implemented")
case ".py": // TODO
return nil, errors.New("convert pytest is not implemented")
case ".jmx": // TODO
return nil, errors.New("convert JMeter jmx is not implemented")
case ".txt":
curlCase, err := LoadCurlCase(inputSample)
if err != nil {
return nil, err
}
return curlCase, nil
}
return nil, fmt.Errorf("unsupported file type: %v", extName)
}
// TCaseConverter holds the common properties of case converter
type TCaseConverter struct {
InputSample string
OutputDir string
TCase *hrp.TCase
}
func (c *TCaseConverter) genOutputPath(suffix string) string {
var outFileFullName string
if curlCmd := strings.TrimSpace(c.InputSample); strings.HasPrefix(curlCmd, "curl ") {
outFileFullName = fmt.Sprintf("curl_%v_test%v", time.Now().Format("20060102150405"), suffix)
if c.OutputDir != "" {
return filepath.Join(c.OutputDir, outFileFullName)
} else {
curWorkDir, _ := os.Getwd()
return filepath.Join(curWorkDir, outFileFullName)
}
}
outFileFullName = builtin.GetFileNameWithoutExtension(c.InputSample) + "_test" + suffix
if c.OutputDir != "" {
return filepath.Join(c.OutputDir, outFileFullName)
} else {
return filepath.Join(filepath.Dir(c.InputSample), outFileFullName)
}
// TODO avoid outFileFullName conflict?
}
// convert TCase to pytest case
func (c *TCaseConverter) ToPyTest() (string, error) {
jsonPath, err := c.ToJSON()
if err != nil {
return "", errors.Wrap(err, "convert to JSON case failed")
}
args := append([]string{"make"}, jsonPath)
err = builtin.ExecPython3Command("httprunner", args...)
if err != nil {
return "", err
}
return c.genOutputPath(suffixPyTest), nil
}
// TODO: convert TCase to gotest case
func (c *TCaseConverter) ToGoTest() (string, error) {
return "", nil
}
// convert TCase to JSON case
func (c *TCaseConverter) ToJSON() (string, error) {
jsonPath := c.genOutputPath(suffixJSON)
err := builtin.Dump2JSON(c.TCase, jsonPath)
if err != nil {
return "", err
}
return jsonPath, nil
}
// convert TCase to YAML case
func (c *TCaseConverter) ToYAML() (string, error) {
yamlPath := c.genOutputPath(suffixYAML)
err := builtin.Dump2YAML(c.TCase, yamlPath)
if err != nil {
return "", err
}
return yamlPath, nil
}
func (c *TCaseConverter) overrideWithProfile(path string) error {
log.Info().Str("path", path).Msg("load profile")
profile := new(Profile)
err := builtin.LoadFile(path, profile)
if err != nil {
log.Warn().Str("path", path).
Msg("failed to load profile, ignore!")
return err
}
log.Info().Interface("profile", profile).Msg("override with profile")
for _, step := range c.TCase.TestSteps {
// override original headers and cookies
if profile.Override {
step.Request.Headers = make(map[string]string)
step.Request.Cookies = make(map[string]string)
}
// update (create if not existed) original headers and cookies
if step.Request.Headers == nil {
step.Request.Headers = make(map[string]string)
}
if step.Request.Cookies == nil {
step.Request.Cookies = make(map[string]string)
}
for k, v := range profile.Headers {
step.Request.Headers[k] = v
}
for k, v := range profile.Cookies {
step.Request.Cookies[k] = v
}
}
return nil
}

View File

@@ -0,0 +1,140 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/httprunner/httprunner/v4/hrp"
)
const (
profilePath = "../../../examples/data/profile.yml"
profileOverridePath = "../../../examples/data/profile_override.yml"
)
func TestLoadTCase(t *testing.T) {
tCase, err := LoadTCase(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NotEmpty(t, tCase) {
t.Fatal()
}
}
func TestLoadHARWithProfileOverride(t *testing.T) {
tCase, err := LoadTCase(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NotEmpty(t, tCase) {
t.Fatal()
}
caseConverter := &TCaseConverter{
TCase: tCase,
}
// override TCase with profile
err = caseConverter.overrideWithProfile(profileOverridePath)
if !assert.NoError(t, err) {
t.Fatal()
}
for i := 0; i < 3; i++ {
if !assert.Equal(t,
map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
caseConverter.TCase.TestSteps[i].Request.Headers) {
t.FailNow()
}
if !assert.Equal(t,
map[string]string{"UserName": "debugtalk"},
caseConverter.TCase.TestSteps[i].Request.Cookies) {
t.FailNow()
}
}
}
func TestMakeRequestWithProfile(t *testing.T) {
caseConverter := &TCaseConverter{
TCase: &hrp.TCase{
TestSteps: []*hrp.TStep{
{
Request: &hrp.Request{
Method: hrp.HTTPMethod("POST"),
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "hrp",
},
Cookies: map[string]string{
"abc": "123",
"UserName": "leolee",
},
},
},
},
},
}
err := caseConverter.overrideWithProfile(profilePath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "hrp",
}, caseConverter.TCase.TestSteps[0].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"UserName": "debugtalk", "abc": "123",
}, caseConverter.TCase.TestSteps[0].Request.Cookies) {
t.Fatal()
}
}
func TestMakeRequestWithProfileOverride(t *testing.T) {
caseConverter := &TCaseConverter{
TCase: &hrp.TCase{
TestSteps: []*hrp.TStep{
{
Request: &hrp.Request{
Method: hrp.HTTPMethod("POST"),
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "hrp",
},
Cookies: map[string]string{
"abc": "123",
"UserName": "leolee",
},
},
},
},
},
}
// override TCase with profile
err := caseConverter.overrideWithProfile(profileOverridePath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
}, caseConverter.TCase.TestSteps[0].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"UserName": "debugtalk",
}, caseConverter.TCase.TestSteps[0].Request.Cookies) {
t.Fatal()
}
}

View File

@@ -0,0 +1,501 @@
package convert
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/google/shlex"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
"github.com/httprunner/httprunner/v4/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.TCase, error) {
cmds, err := builtin.ReadCmdLines(path)
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: &hrp.TConfig{Name: "testcase converted from curl command"},
}
for _, cmd := range cmds {
tSteps, err := LoadCurlSteps(cmd)
if err != nil {
return nil, err
}
tCase.TestSteps = append(tCase.TestSteps, tSteps...)
}
err = tCase.MakeCompat()
if err != nil {
return nil, err
}
return tCase, nil
}
// LoadSingleCurlCase one testcase from one curl command
func LoadSingleCurlCase(cmd string) (*hrp.TCase, error) {
tSteps, err := LoadCurlSteps(cmd)
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: &hrp.TConfig{Name: "testcase converted from curl command"},
TestSteps: tSteps,
}
err = tCase.MakeCompat()
if err != nil {
return nil, err
}
return tCase, nil
}
// 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.Name = 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

@@ -0,0 +1,104 @@
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.TestSteps)) {
t.Fatal()
}
// curl httpbin.org
if !assert.Equal(t, "curl httpbin.org", tCase.TestSteps[0].Name) {
t.Fatal()
}
if !assert.EqualValues(t, "GET", tCase.TestSteps[0].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, "http://httpbin.org", tCase.TestSteps[0].Request.URL) {
t.Fatal()
}
// curl https://httpbin.org/get?key1=value1&key2=value2
if !assert.Equal(t, "https://httpbin.org/get", tCase.TestSteps[1].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value1",
"key2": "value2",
}, tCase.TestSteps[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.TestSteps[2].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Authorization": "Bearer b7d03a6947b217efb6f3ec3bd3504582",
"Content-Type": "application/json",
}, tCase.TestSteps[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.TestSteps[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.TestSteps[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.TestSteps[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.TestSteps[5].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value 1",
"key2": "value:2",
}, tCase.TestSteps[5].Request.Body) {
t.Fatal()
}
}

View File

@@ -0,0 +1,55 @@
package convert
import (
_ "embed"
"os"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
func convert2GoTestScripts(paths ...string) error {
log.Warn().Msg("convert to gotest scripts is not supported yet")
os.Exit(1)
// TODO
var testCasePaths []hrp.ITestCase
for _, path := range paths {
testCasePath := hrp.TestCasePath(path)
testCasePaths = append(testCasePaths, &testCasePath)
}
testCases, err := hrp.LoadTestCases(testCasePaths...)
if err != nil {
log.Error().Err(err).Msg("failed to load testcases")
return err
}
var pytestPaths []string
for _, testCase := range testCases {
tc := testCase.ToTCase()
converter := TCaseConverter{
TCase: tc,
}
pytestPath, err := converter.ToPyTest()
if err != nil {
log.Error().Err(err).
Str("originPath", tc.Config.Path).
Msg("convert to pytest failed")
continue
}
log.Info().
Str("pytestPath", pytestPath).
Str("originPath", tc.Config.Path).
Msg("convert to pytest success")
pytestPaths = append(pytestPaths, pytestPath)
}
// format pytest scripts with black
return builtin.ExecPython3Command("black", pytestPaths...)
}
//go:embed testcase.tmpl
var testcaseTemplate string

623
hrp/pkg/convert/from_har.go Normal file
View File

@@ -0,0 +1,623 @@
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/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
"github.com/httprunner/httprunner/v4/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.TCase, error) {
// load har file
caseHAR, err := loadCaseHAR(path)
if err != nil {
return nil, err
}
// convert to TCase format
return caseHAR.ToTCase()
}
func loadCaseHAR(path string) (*CaseHar, error) {
caseHAR := new(CaseHar)
err := builtin.LoadFile(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 TCase format
func (c *CaseHar) ToTCase() (*hrp.TCase, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: c.prepareConfig(),
TestSteps: teststeps,
}
err = tCase.MakeCompat()
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{},
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

@@ -0,0 +1,282 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/httprunner/httprunner/v4/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.TestSteps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) {
t.Fatal()
}
// make request url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) {
t.Fatal()
}
// make request params
if !assert.Equal(t, "HDnY8", tCase.TestSteps[0].Request.Params["foo1"]) {
t.Fatal()
}
// make request cookies
if !assert.NotEmpty(t, tCase.TestSteps[1].Request.Cookies["sails.sid"]) {
t.Fatal()
}
// make request headers
if !assert.Equal(t, "HttpRunnerPlus", tCase.TestSteps[0].Request.Headers["User-Agent"]) {
t.Fatal()
}
if !assert.Equal(t, "postman-echo.com", tCase.TestSteps[0].Request.Headers["Host"]) {
t.Fatal()
}
// make request data
if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.TestSteps[1].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"foo1": "HDnY8", "foo2": "12.3"}, tCase.TestSteps[2].Request.Body) {
t.Fatal()
}
// make validators
validator, ok := tCase.TestSteps[0].Validators[0].(hrp.Validator)
if !ok || !assert.Equal(t, "status_code", validator.Check) {
t.Fatal()
}
validator, ok = tCase.TestSteps[0].Validators[1].(hrp.Validator)
if !ok || !assert.Equal(t, "headers.\"Content-Type\"", validator.Check) {
t.Fatal()
}
validator, ok = tCase.TestSteps[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

@@ -0,0 +1,27 @@
package convert
import (
"github.com/pkg/errors"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
func LoadJSONCase(path string) (*hrp.TCase, error) {
// load json case file
caseJSON := new(hrp.TCase)
err := builtin.LoadFile(path, caseJSON)
if err != nil {
return nil, errors.Wrap(err, "load json file failed")
}
if caseJSON.TestSteps == nil {
return nil, errors.New("invalid json case file, missing teststeps")
}
err = caseJSON.MakeCompat()
if err != nil {
return nil, err
}
return caseJSON, nil
}

View File

@@ -0,0 +1,393 @@
package convert
import (
"fmt"
"net/url"
"reflect"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
"github.com/httprunner/httprunner/v4/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.TCase, error) {
// load postman file
casePostman, err := loadCasePostman(path)
if err != nil {
return nil, err
}
// convert to TCase format
return casePostman.ToTCase()
}
func loadCasePostman(path string) (*CasePostman, error) {
casePostman := new(CasePostman)
err := builtin.LoadFile(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) ToTCase() (*hrp.TCase, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TCase{
Config: c.prepareConfig(),
TestSteps: teststeps,
}
err = tCase.MakeCompat()
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{},
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.Name = 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)
}
}()
// extract language type, default languageType: text
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)
}
}
// make request body and indicate Content-Type
rawBody := item.Request.Body.Raw
if languageType == "json" {
var iBody interface{}
err = json.Unmarshal([]byte(rawBody), &iBody)
if err != nil {
return errors.Wrap(err, "make request body (raw -> json) failed")
}
s.Request.Body = iBody
} else {
s.Request.Body = rawBody
}
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

@@ -0,0 +1,78 @@
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.TestSteps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) {
t.Fatal()
}
// check url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) {
t.Fatal()
}
// check params
if !assert.Equal(t, "v1", tCase.TestSteps[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.TestSteps[2].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "application/json", tCase.TestSteps[3].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "text/plain", tCase.TestSteps[4].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "HttpRunner", tCase.TestSteps[5].Request.Headers["User-Agent"]) {
t.Fatal()
}
// check body
if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"k1": "v1", "k2": "v2"}, tCase.TestSteps[2].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"k1": "v1", "k2": "v2"}, tCase.TestSteps[3].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, "have a nice day", tCase.TestSteps[4].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, nil, tCase.TestSteps[5].Request.Body) {
t.Fatal()
}
}

View File

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

View File

@@ -0,0 +1,24 @@
package convert
import (
"github.com/go-openapi/spec"
"github.com/pkg/errors"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
func LoadSwaggerCase(path string) (*hrp.TCase, error) {
// load swagger file
caseSwagger := new(spec.Swagger)
err := builtin.LoadFile(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

@@ -0,0 +1,28 @@
package convert
import (
"reflect"
"github.com/pkg/errors"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
func NewYAMLCase(path string) (*hrp.TCase, error) {
// load yaml case file
caseJSON := new(hrp.TCase)
err := builtin.LoadFile(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 = caseJSON.MakeCompat()
if err != nil {
return nil, err
}
return caseJSON, nil
}

View File

@@ -0,0 +1,38 @@
# NOTE: Generated By HttpRunner v{{ version }}
# FROM: {{ testcase_path }}
{% if imports_list and diff_levels > 0 %}
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__){% for _ in range(diff_levels) %}.parent{% endfor %}))
{% endif %}
{% if parameters %}
import pytest
from httprunner import Parameters
{% endif %}
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
{% for import_str in imports_list %}
{{ import_str }}
{% endfor %}
class {{ class_name }}(HttpRunner):
{% if parameters %}
@pytest.mark.parametrize("param", Parameters({{parameters}}))
def test_start(self, param):
super().test_start(param)
{% endif %}
config = {{ config_chain_style }}
teststeps = [
{% for step_chain_style in teststeps_chain_style %}
{{ step_chain_style }},
{% endfor %}
]
if __name__ == "__main__":
{{ class_name }}().test_start()

View File

@@ -0,0 +1,38 @@
package demo
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/httprunner/httprunner/v4/hrp/pkg/httpstat"
)
func TestMain(t *testing.T) {
var httpStat httpstat.Stat
req, _ := http.NewRequest("GET", "https://httprunner.com", nil)
ctx := httpstat.WithHTTPStat(req, &httpStat)
client := &http.Client{
Timeout: time.Second * 10,
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if resp != nil {
defer resp.Body.Close()
}
// get stat
httpStat.Finish()
result := httpStat.Durations()
fmt.Println(result)
// print stat
httpStat.Print()
}

View File

@@ -67,7 +67,7 @@ func InitWDAClient(device *IOSDevice) (*DriverExt, error) {
}
// switch to iOS springboard before init WDA session
// aviod getting stuck when some super app is activate such as douyin or wexin
// avoid getting stuck when some super app is activate such as douyin or wexin
log.Info().Msg("go back to home screen")
if err = driver.Homescreen(); err != nil {
return nil, errors.Wrap(err, "failed to go back to home screen")