From a5dc6c7635ad71294e5abd40e048abe28bf050bd Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Thu, 12 May 2022 21:37:30 +0800 Subject: [PATCH 01/14] feat: postman2case --- README.en.md | 2 +- README.md | 2 +- docs/CHANGELOG.md | 1 + .../data/postman2case/postman_collection.json | 346 +++++++++++++++++ hrp/cmd/postman2case.go | 65 ++++ hrp/internal/postman2case/collection.go | 74 ++++ hrp/internal/postman2case/core.go | 364 ++++++++++++++++++ hrp/internal/postman2case/core_test.go | 39 ++ 8 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 examples/data/postman2case/postman_collection.json create mode 100644 hrp/cmd/postman2case.go create mode 100644 hrp/internal/postman2case/collection.go create mode 100644 hrp/internal/postman2case/core.go create mode 100644 hrp/internal/postman2case/core_test.go diff --git a/README.en.md b/README.en.md index d1a564e0..555de35a 100644 --- a/README.en.md +++ b/README.en.md @@ -108,7 +108,7 @@ Use "hrp [command] --help" for more information about a command. 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner 如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 diff --git a/README.md b/README.md index 0581a634..f8949730 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ HttpRunner is in Sentry Sponsored plan. 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner 如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3dc23dd..118ede44 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,7 @@ - fix: step request elapsed timing should contain ContentTransfer part - fix #1288: unable to go get httprunner v4 +- feat: support converting Postman collection to HttpRunner testcase **python version** diff --git a/examples/data/postman2case/postman_collection.json b/examples/data/postman2case/postman_collection.json new file mode 100644 index 00000000..5cedbcf8 --- /dev/null +++ b/examples/data/postman2case/postman_collection.json @@ -0,0 +1,346 @@ +{ + "info": { + "_postman_id": "0417a445-b206-4ea2-b1d2-5441afd6c6b9", + "name": "postman collection demo", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "folder1", + "item": [ + { + "name": "folder2", + "item": [ + { + "name": "Get with params", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "response": [ + { + "name": "Get with params", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "_postman_previewlanguage": "json", + "header": null, + "cookie": [], + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k2=v2\"\n}" + } + ] + } + ] + } + ] + }, + { + "name": "folder3", + "item": [ + { + "name": "Post form-data", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post x-www-form-urlencoded", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw json", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw text", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "have a nice day", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get request headers", + "request": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "response": [ + { + "name": "Get request headers", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "_postman_previewlanguage": "json", + "header": null, + "cookie": [], + "body": "{\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"HttpRunner\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n }\n}" + } + ] + } + ] +} \ No newline at end of file diff --git a/hrp/cmd/postman2case.go b/hrp/cmd/postman2case.go new file mode 100644 index 00000000..5ccedacb --- /dev/null +++ b/hrp/cmd/postman2case.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "errors" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp/internal/postman2case" +) + +// postman2caseCmd represents the postman2case command +var postman2caseCmd = &cobra.Command{ + Use: "postman2case $postman_path...", + Short: "convert postman collection to json/yaml testcase files", + Long: `convert postman collection to json/yaml testcase files`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + var outputFiles []string + for _, arg := range args { + // must choose one + if !postman2JSONFlag && !postman2YAMLFlag { + return errors.New("please select convert format type") + } + var outputPath string + var err error + + postman := postman2case.NewCollection(arg) + + // specify output dir + if postman2Dir != "" { + postman.SetOutputDir(postman2Dir) + } + + // generate json/yaml files + if genYAMLFlag { + outputPath, err = postman.GenYAML() + } else { + outputPath, err = postman.GenJSON() // default + } + if err != nil { + return err + } + outputFiles = append(outputFiles, outputPath) + } + log.Info().Strs("output", outputFiles).Msg("convert testcase success") + return nil + }, +} + +var ( + postman2JSONFlag bool + postman2YAMLFlag bool + postman2Dir string +) + +func init() { + rootCmd.AddCommand(postman2caseCmd) + postman2caseCmd.Flags().BoolVarP(&postman2JSONFlag, "to-json", "j", true, "convert to JSON format") + postman2caseCmd.Flags().BoolVarP(&postman2YAMLFlag, "to-yaml", "y", false, "convert to YAML format") + postman2caseCmd.Flags().StringVarP(&postman2Dir, "output-dir", "d", "", "specify output directory, default to the same dir with postman collection file") +} diff --git a/hrp/internal/postman2case/collection.go b/hrp/internal/postman2case/collection.go new file mode 100644 index 00000000..ddabee21 --- /dev/null +++ b/hrp/internal/postman2case/collection.go @@ -0,0 +1,74 @@ +package postman2case + +/* +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 +*/ + +// TCollection represents the postman exported file +type TCollection 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"` +} diff --git a/hrp/internal/postman2case/core.go b/hrp/internal/postman2case/core.go new file mode 100644 index 00000000..f4b8b4e3 --- /dev/null +++ b/hrp/internal/postman2case/core.go @@ -0,0 +1,364 @@ +package postman2case + +import ( + "bytes" + "fmt" + "github.com/httprunner/httprunner/v4/hrp/internal/json" + "io" + "mime/multipart" + "net/url" + "os" + "path/filepath" + "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" +) + +const ( + enumBodyRaw = "raw" + enumBodyUrlEncoded = "urlencoded" + enumBodyFormData = "formdata" + enumBodyFile = "file" + enumBodyGraphQL = "graphql" +) + +const ( + enumFieldTypeText = "text" + enumFieldTypeFile = "file" +) + +const ( + suffixName = ".converted" + extensionJSON = ".json" + extensionYAML = ".yaml" +) + +var contentTypeMap = map[string]string{ + "text": "text/plain", + "javascript": "application/javascript", + "json": "application/json", + "html": "text/html", + "xml": "application/xml", +} + +func NewCollection(path string) *collection { + return &collection{ + path: path, + } +} + +type collection struct { + path string + outputDir string +} + +func (c *collection) SetOutputDir(dir string) { + log.Info().Str("dir", dir).Msg("set output directory") + c.outputDir = dir +} + +func (c *collection) GenJSON() (jsonPath string, err error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath = c.genOutputPath(extensionJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + return +} + +func (c *collection) GenYAML() (yamlPath string, err error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath = c.genOutputPath(extensionYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + return +} + +func (c *collection) genOutputPath(suffix string) string { + file := getFilenameWithoutExtension(c.path) + suffix + if c.outputDir != "" { + return filepath.Join(c.outputDir, file) + } else { + return filepath.Join(filepath.Dir(c.path), file) + } +} + +func getFilenameWithoutExtension(path string) string { + base := filepath.Base(path) + ext := filepath.Ext(base) + return base[0:len(base)-len(ext)] + suffixName +} + +func (c *collection) makeTestCase() (*hrp.TCase, error) { + tCollection, err := c.load() + if err != nil { + return nil, err + } + teststeps, err := c.prepareTestSteps(tCollection) + if err != nil { + return nil, err + } + tCase := &hrp.TCase{ + Config: c.prepareConfig(tCollection), + TestSteps: teststeps, + } + return tCase, nil +} + +func (c *collection) load() (*TCollection, error) { + collection := &TCollection{} + err := builtin.LoadFile(c.path, collection) + if err != nil { + return nil, errors.Wrap(err, "load postman collection failed") + } + return collection, nil +} + +func (c *collection) prepareConfig(tCollection *TCollection) *hrp.TConfig { + return hrp.NewConfig(tCollection.Info.Name). + SetVerifySSL(false) +} + +func (c *collection) prepareTestSteps(tCollection *TCollection) ([]*hrp.TStep, error) { + // recursively convert collection items into a list + var itemList []TItem + for _, item := range tCollection.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 *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { + log.Info(). + Str("method", item.Request.Method). + Str("url", item.Request.URL.Raw). + Msg("convert teststep") + + step := &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.makeRequestHeadersAndCookies(item); err != nil { + return nil, err + } + if err := step.makeRequestBody(item); err != nil { + return nil, err + } + if err := step.makeValidate(item); err != nil { + return nil, err + } + return &step.TStep, nil +} + +type tStep struct { + hrp.TStep +} + +// makeRequestName indicates the step name the same as item name +func (s *tStep) makeRequestName(item *TItem) error { + s.Name = item.Name + return nil +} + +func (s *tStep) makeRequestMethod(item *TItem) error { + s.Request.Method = hrp.HTTPMethod(item.Request.Method) + return nil +} + +func (s *tStep) 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 *tStep) 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 *tStep) makeRequestHeadersAndCookies(item *TItem) error { + s.Request.Headers = make(map[string]string) + for _, field := range item.Request.Headers { + if field.Disabled { + continue + } + if strings.EqualFold(field.Key, "cookie") { + s.Request.Cookies[field.Key] = field.Value + continue + } + s.Request.Headers[field.Key] = field.Value + } + return nil +} + +func (s *tStep) 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.New("not supported body type") + } + return nil +} + +func (s *tStep) 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 + iOptions := item.Request.Body.Options + iLanguage := iOptions.(map[string]interface{})["raw"] + 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 failed") + } + s.Request.Body = iBody + } else { + s.Request.Body = rawBody + } + s.Request.Headers["Content-Type"] = contentTypeMap[languageType] + return +} + +func (s *tStep) makeRequestBodyFormData(item *TItem) (err error) { + defer func() { + if err != nil { + err = errors.Wrap(err, "make request body form-data failed") + } + }() + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + for _, field := range item.Request.Body.FormData { + if field.Disabled { + continue + } + // form data could be text or file + if field.Type == enumFieldTypeText { + err = writer.WriteField(field.Key, field.Value) + if err != nil { + return + } + } else if field.Type == enumFieldTypeFile { + err = writeFormDataFile(writer, &field) + if err != nil { + return + } + } + } + err = writer.Close() + s.Request.Body = payload.String() + s.Request.Headers["Content-Type"] = writer.FormDataContentType() + return +} + +func writeFormDataFile(writer *multipart.Writer, field *TField) error { + file, err := os.Open(field.Src) + if err != nil { + return err + } + defer file.Close() + formFile, err := writer.CreateFormFile(field.Key, filepath.Base(field.Src)) + if err != nil { + return err + } + _, err = io.Copy(formFile, file) + return err +} + +func (s *tStep) 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 example response +func (s *tStep) makeValidate(item *TItem) error { + return nil +} diff --git a/hrp/internal/postman2case/core_test.go b/hrp/internal/postman2case/core_test.go new file mode 100644 index 00000000..47b9eabc --- /dev/null +++ b/hrp/internal/postman2case/core_test.go @@ -0,0 +1,39 @@ +package postman2case + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var collectionPath = "../../../examples/data/postman2case/postman_collection.json" + +func TestLoadPostmanCollection(t *testing.T) { + c, err := NewCollection(collectionPath).load() + if !assert.NoError(t, err) { + t.Fatal(err) + } + if !assert.Equal(t, "postman collection demo", c.Info.Name) { + t.Fatal() + } +} + +func TestGenJSON(t *testing.T) { + jsonPath, err := NewCollection(collectionPath).GenJSON() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fatal() + } +} + +func TestGenYAML(t *testing.T) { + yamlPath, err := NewCollection(collectionPath).GenYAML() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fatal() + } +} From 76bf2309ed36a3b99350893224c63f08c65ab000 Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Tue, 17 May 2022 16:03:45 +0800 Subject: [PATCH 02/14] add unittest; add --patch options --- docs/cmd/hrp_postman2case.md | 26 +++ examples/data/har/demo.json | 128 --------------- .../{postman_collection.json => demo.json} | 154 ++++++++++++++++- examples/data/postman2case/patch.yml | 4 + examples/data/postman2case/profile.yml | 4 + hrp/cmd/convert.go | 7 +- hrp/cmd/har2case.go | 37 +++-- hrp/cmd/postman2case.go | 42 +++-- .../convert/{ => case2script}/main.go | 2 +- .../convert/{ => case2script}/testcase.tmpl | 0 hrp/internal/{ => convert}/har2case/README.md | 0 hrp/internal/{ => convert}/har2case/core.go | 87 +++++++--- .../{ => convert}/har2case/core_test.go | 36 +++- hrp/internal/{ => convert}/har2case/har.go | 0 .../{ => convert}/postman2case/collection.go | 0 .../{ => convert}/postman2case/core.go | 131 +++++++++++++-- .../convert/postman2case/core_test.go | 155 ++++++++++++++++++ hrp/internal/postman2case/core_test.go | 39 ----- 18 files changed, 604 insertions(+), 248 deletions(-) create mode 100644 docs/cmd/hrp_postman2case.md delete mode 100644 examples/data/har/demo.json rename examples/data/postman2case/{postman_collection.json => demo.json} (58%) create mode 100644 examples/data/postman2case/patch.yml create mode 100644 examples/data/postman2case/profile.yml rename hrp/internal/convert/{ => case2script}/main.go (99%) rename hrp/internal/convert/{ => case2script}/testcase.tmpl (100%) rename hrp/internal/{ => convert}/har2case/README.md (100%) rename hrp/internal/{ => convert}/har2case/core.go (84%) rename hrp/internal/{ => convert}/har2case/core_test.go (90%) rename hrp/internal/{ => convert}/har2case/har.go (100%) rename hrp/internal/{ => convert}/postman2case/collection.go (100%) rename hrp/internal/{ => convert}/postman2case/core.go (72%) create mode 100644 hrp/internal/convert/postman2case/core_test.go delete mode 100644 hrp/internal/postman2case/core_test.go diff --git a/docs/cmd/hrp_postman2case.md b/docs/cmd/hrp_postman2case.md new file mode 100644 index 00000000..23c196e7 --- /dev/null +++ b/docs/cmd/hrp_postman2case.md @@ -0,0 +1,26 @@ +## hrp postman2case + +convert postman collection to json/yaml testcase files + +### Synopsis + +convert postman collection to json/yaml testcase files + +``` +hrp postman2case $postman_path... [flags] +``` + +### Options + +``` + -h, --help help for postman2case + -d, --output-dir string specify output directory, default to the same dir with postman collection file + -j, --to-json convert to JSON format (default true) + -y, --to-yaml convert to YAML format +``` + +### SEE ALSO + +* [hrp](hrp.md) - Next-Generation API Testing Solution. + +###### Auto generated by spf13/cobra on 12-May-2022 diff --git a/examples/data/har/demo.json b/examples/data/har/demo.json deleted file mode 100644 index 292ad513..00000000 --- a/examples/data/har/demo.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "config": { - "name": "testcase description" - }, - "teststeps": [ - { - "name": "", - "request": { - "method": "GET", - "url": "https://postman-echo.com/get", - "params": { - "foo1": "HDnY8", - "foo2": "34.5" - }, - "headers": { - "Accept-Encoding": "gzip", - "Host": "postman-echo.com", - "User-Agent": "HttpRunnerPlus" - } - }, - "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=HDnY8\u0026foo2=34.5", - "msg": "assert response body url" - } - ] - }, - { - "name": "", - "request": { - "method": "POST", - "url": "https://postman-echo.com/post", - "headers": { - "Accept-Encoding": "gzip", - "Content-Length": "28", - "Content-Type": "application/json; charset=UTF-8", - "Host": "postman-echo.com", - "User-Agent": "Go-http-client/1.1" - }, - "cookies": { - "sails.sid": "s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk" - }, - "body": { - "foo1": "HDnY8", - "foo2": 12.3 - } - }, - "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/post", - "msg": "assert response body url" - } - ] - }, - { - "name": "", - "request": { - "method": "POST", - "url": "https://postman-echo.com/post", - "headers": { - "Accept-Encoding": "gzip", - "Content-Length": "20", - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Host": "postman-echo.com", - "User-Agent": "Go-http-client/1.1" - }, - "cookies": { - "sails.sid": "s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw" - }, - "body": "foo1=HDnY8\u0026foo2=12.3" - }, - "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": "", - "msg": "assert response body data" - }, - { - "check": "body.url", - "assert": "equals", - "expect": "https://postman-echo.com/post", - "msg": "assert response body url" - } - ] - } - ] -} \ No newline at end of file diff --git a/examples/data/postman2case/postman_collection.json b/examples/data/postman2case/demo.json similarity index 58% rename from examples/data/postman2case/postman_collection.json rename to examples/data/postman2case/demo.json index 5cedbcf8..3b7a9e30 100644 --- a/examples/data/postman2case/postman_collection.json +++ b/examples/data/postman2case/demo.json @@ -51,7 +51,7 @@ }, "response": [ { - "name": "Get with params", + "name": "Get with params case1", "originalRequest": { "method": "GET", "header": [], @@ -88,10 +88,115 @@ ] } }, + "status": "OK", + "code": 200, "_postman_previewlanguage": "json", - "header": null, + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:12:28 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "508" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"1fc-x4EIPFQzoLX0HenCFPx6HNfG0lc\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + }, + { + "key": "set-cookie", + "value": "sails.sid=s%3AX2aa_Z7gbcUqIWAjlBkytBRmQ4WCvc3D.pX9Qxh8aO9Ict0BL4CrRhdDJmz81UVmwFsV5Nx30Ils; Path=/; HttpOnly" + } + ], "cookie": [], - "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k2=v2\"\n}" + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k2=v2\"\n}" + }, + { + "name": "Get with params case2", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k3=v3", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2", + "disabled": true + }, + { + "key": "k3", + "value": "v3" + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:14:04 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "504" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"1f8-tMaKs4xmwr+3su3I8mcgR0p+ucw\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + }, + { + "key": "set-cookie", + "value": "sails.sid=s%3AMNuX_i0KgaP_KuuMpYB8RtCNipCGJWVw.4ETfPHxE81Omqb6Yli%2FezUU8CXyYBcN3%2Bxkx5htwh8Y; Path=/; HttpOnly" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k3\": \"v3\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AX2aa_Z7gbcUqIWAjlBkytBRmQ4WCvc3D.pX9Qxh8aO9Ict0BL4CrRhdDJmz81UVmwFsV5Nx30Ils\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k3=v3\"\n}" } ] } @@ -279,6 +384,11 @@ "value": "bbx", "type": "text", "disabled": true + }, + { + "key": "Connection", + "value": "close", + "type": "text" } ], "url": { @@ -301,7 +411,7 @@ }, "response": [ { - "name": "Get request headers", + "name": "Get request headers case1", "originalRequest": { "method": "GET", "header": [ @@ -315,6 +425,11 @@ "value": "bbx", "type": "text", "disabled": true + }, + { + "key": "Cookie", + "value": "Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY", + "type": "text" } ], "url": { @@ -335,10 +450,37 @@ ] } }, + "status": "OK", + "code": 200, "_postman_previewlanguage": "json", - "header": null, + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:14:25 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "541" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"21d-ld5UvFTaRM6lihVnvCj6mZm5Of0\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + } + ], "cookie": [], - "body": "{\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"HttpRunner\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n }\n}" + "body": "{\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"HttpRunner\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n }\n}" } ] } diff --git a/examples/data/postman2case/patch.yml b/examples/data/postman2case/patch.yml new file mode 100644 index 00000000..c657b5ef --- /dev/null +++ b/examples/data/postman2case/patch.yml @@ -0,0 +1,4 @@ +headers: + User-Agent: "this header will be created or updated" +cookies: + Cookie1: "this cookie will be created or updated" diff --git a/examples/data/postman2case/profile.yml b/examples/data/postman2case/profile.yml new file mode 100644 index 00000000..42e2e9f4 --- /dev/null +++ b/examples/data/postman2case/profile.yml @@ -0,0 +1,4 @@ +headers: + Header1: "all original headers will be overridden" +cookies: + Cookie1: "all original cookies will be overridden" \ No newline at end of file diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go index 0247d147..48a9f4bc 100644 --- a/hrp/cmd/convert.go +++ b/hrp/cmd/convert.go @@ -7,7 +7,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/convert" + "github.com/httprunner/httprunner/v4/hrp/internal/convert/case2script" ) var convertCmd = &cobra.Command{ @@ -18,15 +18,16 @@ var convertCmd = &cobra.Command{ setLogLevel(logLevel) }, RunE: func(cmd *cobra.Command, args []string) error { + // TODO: integrate har2case, postman2case, etc. in convert command (forward compatibility) if !pytestFlag && !gotestFlag { return errors.New("please specify convertion type") } var err error if gotestFlag { - err = convert.Convert2TestScripts("gotest", args...) + err = case2script.Convert2TestScripts("gotest", args...) } else { - err = convert.Convert2TestScripts("pytest", args...) + err = case2script.Convert2TestScripts("pytest", args...) } if err != nil { log.Error().Err(err).Msg("convert test scripts failed") diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go index eecd40cc..42fab1bd 100644 --- a/hrp/cmd/har2case.go +++ b/hrp/cmd/har2case.go @@ -6,7 +6,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/har2case" + "github.com/httprunner/httprunner/v4/hrp/internal/convert/har2case" ) // har2caseCmd represents the har2case command @@ -22,7 +22,7 @@ var har2caseCmd = &cobra.Command{ var outputFiles []string for _, arg := range args { // must choose one - if !genYAMLFlag && !genJSONFlag { + if !har2caseGenYAMLFlag && !har2caseGenJSONFlag { return errors.New("please select convert format type") } var outputPath string @@ -31,17 +31,22 @@ var har2caseCmd = &cobra.Command{ har := har2case.NewHAR(arg) // specify output dir - if outputDir != "" { - har.SetOutputDir(outputDir) + if har2caseOutputDir != "" { + har.SetOutputDir(har2caseOutputDir) } // specify profile - if profilePath != "" { - har.SetProfile(profilePath) + if har2caseProfilePath != "" { + har.SetProfile(har2caseProfilePath) + } + + // specify profile + if har2casePatchPath != "" { + har.SetPatch(har2casePatchPath) } // generate json/yaml files - if genYAMLFlag { + if har2caseGenYAMLFlag { outputPath, err = har.GenYAML() } else { outputPath, err = har.GenJSON() // default @@ -57,16 +62,18 @@ var har2caseCmd = &cobra.Command{ } var ( - genJSONFlag bool - genYAMLFlag bool - outputDir string - profilePath string + har2caseGenJSONFlag bool + har2caseGenYAMLFlag bool + har2caseOutputDir string + har2caseProfilePath string + har2casePatchPath string ) func init() { rootCmd.AddCommand(har2caseCmd) - har2caseCmd.Flags().BoolVarP(&genJSONFlag, "to-json", "j", true, "convert to JSON format") - har2caseCmd.Flags().BoolVarP(&genYAMLFlag, "to-yaml", "y", false, "convert to YAML format") - har2caseCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") - har2caseCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies") + har2caseCmd.Flags().BoolVarP(&har2caseGenJSONFlag, "to-json", "j", true, "convert to JSON format") + har2caseCmd.Flags().BoolVarP(&har2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") + har2caseCmd.Flags().StringVarP(&har2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") + har2caseCmd.Flags().StringVarP(&har2caseProfilePath, "profile", "p", "", "specify profile path to override headers and cookies") + har2caseCmd.Flags().StringVarP(&har2casePatchPath, "patch", "r", "", "specify the path of the file used to replace headers and cookies") } diff --git a/hrp/cmd/postman2case.go b/hrp/cmd/postman2case.go index 5ccedacb..2e0c1369 100644 --- a/hrp/cmd/postman2case.go +++ b/hrp/cmd/postman2case.go @@ -6,7 +6,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/postman2case" + "github.com/httprunner/httprunner/v4/hrp/internal/convert/postman2case" ) // postman2caseCmd represents the postman2case command @@ -22,24 +22,34 @@ var postman2caseCmd = &cobra.Command{ var outputFiles []string for _, arg := range args { // must choose one - if !postman2JSONFlag && !postman2YAMLFlag { + if !postman2caseGenJSONFlag && !postman2caseGenYAMLFlag { return errors.New("please select convert format type") } var outputPath string var err error - postman := postman2case.NewCollection(arg) + collection := postman2case.NewCollection(arg) // specify output dir - if postman2Dir != "" { - postman.SetOutputDir(postman2Dir) + if postman2caseOutputDir != "" { + collection.SetOutputDir(postman2caseOutputDir) + } + + // specify profile path + if postman2caseProfilePath != "" { + collection.SetProfile(postman2caseProfilePath) + } + + // specify patch path + if postman2casePatchPath != "" { + collection.SetPatch(postman2casePatchPath) } // generate json/yaml files - if genYAMLFlag { - outputPath, err = postman.GenYAML() + if postman2caseGenYAMLFlag { + outputPath, err = collection.GenYAML() } else { - outputPath, err = postman.GenJSON() // default + outputPath, err = collection.GenJSON() // default } if err != nil { return err @@ -52,14 +62,18 @@ var postman2caseCmd = &cobra.Command{ } var ( - postman2JSONFlag bool - postman2YAMLFlag bool - postman2Dir string + postman2caseGenJSONFlag bool + postman2caseGenYAMLFlag bool + postman2caseOutputDir string + postman2caseProfilePath string + postman2casePatchPath string ) func init() { rootCmd.AddCommand(postman2caseCmd) - postman2caseCmd.Flags().BoolVarP(&postman2JSONFlag, "to-json", "j", true, "convert to JSON format") - postman2caseCmd.Flags().BoolVarP(&postman2YAMLFlag, "to-yaml", "y", false, "convert to YAML format") - postman2caseCmd.Flags().StringVarP(&postman2Dir, "output-dir", "d", "", "specify output directory, default to the same dir with postman collection file") + postman2caseCmd.Flags().BoolVarP(&postman2caseGenJSONFlag, "to-json", "j", true, "convert to JSON format") + postman2caseCmd.Flags().BoolVarP(&postman2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") + postman2caseCmd.Flags().StringVarP(&postman2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with postman collection file") + postman2caseCmd.Flags().StringVarP(&postman2caseProfilePath, "profile", "p", "", "specify profile path to override original headers (except for Content-Type) and cookies") + postman2caseCmd.Flags().StringVarP(&postman2casePatchPath, "patch", "r", "", "specify patch path to create or update headers and cookies") } diff --git a/hrp/internal/convert/main.go b/hrp/internal/convert/case2script/main.go similarity index 99% rename from hrp/internal/convert/main.go rename to hrp/internal/convert/case2script/main.go index ea58dd6e..bfc75b27 100644 --- a/hrp/internal/convert/main.go +++ b/hrp/internal/convert/case2script/main.go @@ -1,4 +1,4 @@ -package convert +package case2script import ( _ "embed" diff --git a/hrp/internal/convert/testcase.tmpl b/hrp/internal/convert/case2script/testcase.tmpl similarity index 100% rename from hrp/internal/convert/testcase.tmpl rename to hrp/internal/convert/case2script/testcase.tmpl diff --git a/hrp/internal/har2case/README.md b/hrp/internal/convert/har2case/README.md similarity index 100% rename from hrp/internal/har2case/README.md rename to hrp/internal/convert/har2case/README.md diff --git a/hrp/internal/har2case/core.go b/hrp/internal/convert/har2case/core.go similarity index 84% rename from hrp/internal/har2case/core.go rename to hrp/internal/convert/har2case/core.go index 25824855..0e96a96d 100644 --- a/hrp/internal/har2case/core.go +++ b/hrp/internal/convert/har2case/core.go @@ -22,6 +22,13 @@ const ( suffixYAML = ".yaml" ) +const ( + configProfile = "profile" + configPatch = "patch" + keyHeaders = "headers" + keyCookies = "cookies" +) + func NewHAR(path string) *har { return &har{ path: path, @@ -33,6 +40,7 @@ type har struct { filterStr string excludeStr string profile map[string]interface{} + patch map[string]interface{} outputDir string } @@ -46,6 +54,16 @@ func (h *har) SetProfile(path string) { } } +func (h *har) SetPatch(path string) { + log.Info().Str("path", path).Msg("set patch") + h.patch = make(map[string]interface{}) + err := builtin.LoadFile(path, h.patch) + if err != nil { + log.Warn().Str("path", path). + Msg("invalid patch format, ignore!") + } +} + func (h *har) SetOutputDir(dir string) { log.Info().Str("dir", dir).Msg("set output directory") h.outputDir = dir @@ -146,6 +164,7 @@ func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { Validators: make([]interface{}, 0), }, profile: h.profile, + patch: h.patch, } if err := step.makeRequestMethod(entry); err != nil { return nil, err @@ -174,6 +193,7 @@ func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { type tStep struct { hrp.TStep profile map[string]interface{} + patch map[string]interface{} } func (s *tStep) makeRequestMethod(entry *Entry) error { @@ -199,43 +219,59 @@ func (s *tStep) makeRequestParams(entry *Entry) error { return nil } +func (s *tStep) updateRequestInfo(config string, key string) bool { + var m map[string]interface{} + switch config { + case configProfile: + m = s.profile + case configPatch: + m = s.patch + default: + return false + } + iRequestMap, existed := m[key] + if existed { + requestMap, ok := iRequestMap.(map[string]interface{}) + if ok { + for k, v := range requestMap { + switch key { + case keyHeaders: + s.Request.Headers[k] = fmt.Sprintf("%v", v) + case keyCookies: + s.Request.Cookies[k] = fmt.Sprintf("%v", v) + } + } + return true + } + log.Warn().Interface(key, iRequestMap).Msgf("%v from %v is not a map, ignore!", key, config) + } + return false +} + func (s *tStep) makeRequestCookies(entry *Entry) error { s.Request.Cookies = make(map[string]string) - cookies, ok := s.profile["cookies"] - if ok { - // use cookies from profile - cookies, ok := cookies.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Cookies[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("cookies", cookies). - Msg("cookies from profile is not a map, ignore!") + + // override all cookies according to the profile + if s.updateRequestInfo(configProfile, keyCookies) { + return nil } // use cookies from har for _, cookie := range entry.Request.Cookies { s.Request.Cookies[cookie.Name] = cookie.Value } + + // create or update the cookies indicated in the patch + s.updateRequestInfo(configPatch, keyCookies) return nil } func (s *tStep) makeRequestHeaders(entry *Entry) error { s.Request.Headers = make(map[string]string) - headers, ok := s.profile["headers"] - if ok { - // use headers from profile - cookies, ok := headers.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Headers[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("headers", headers). - Msg("headers from profile is not a map, ignore!") + + // override all headers according to the profile + if s.updateRequestInfo(configProfile, keyHeaders) { + return nil } // use headers from har @@ -245,6 +281,9 @@ func (s *tStep) makeRequestHeaders(entry *Entry) error { } s.Request.Headers[header.Name] = header.Value } + + // create or update the headers indicated in the patch + s.updateRequestInfo(configPatch, keyHeaders) return nil } diff --git a/hrp/internal/har2case/core_test.go b/hrp/internal/convert/har2case/core_test.go similarity index 90% rename from hrp/internal/har2case/core_test.go rename to hrp/internal/convert/har2case/core_test.go index de2ee910..ce6466fe 100644 --- a/hrp/internal/har2case/core_test.go +++ b/hrp/internal/convert/har2case/core_test.go @@ -1,6 +1,7 @@ package har2case import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -9,9 +10,9 @@ import ( ) var ( - harPath = "../../../examples/data/har/demo.har" - harPath2 = "../../../examples/data/har/postman-echo.har" - profilePath = "../../../examples/data/har/profile.yml" + harPath = "../../../../examples/data/har/demo.har" + harPath2 = "../../../../examples/data/har/postman-echo.har" + profilePath = "../../../../examples/data/har/profile.yml" ) func TestGenJSON(t *testing.T) { @@ -381,3 +382,32 @@ func TestMakeValidate(t *testing.T) { t.Fatal() } } + +func Test_tStep_makeRequestCookies(t *testing.T) { + type fields struct { + TStep hrp.TStep + profile map[string]interface{} + patch map[string]interface{} + } + type args struct { + entry *Entry + } + tests := []struct { + name string + fields fields + args args + wantErr assert.ErrorAssertionFunc + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &tStep{ + TStep: tt.fields.TStep, + profile: tt.fields.profile, + patch: tt.fields.patch, + } + tt.wantErr(t, s.makeRequestCookies(tt.args.entry), fmt.Sprintf("makeRequestCookies(%v)", tt.args.entry)) + }) + } +} diff --git a/hrp/internal/har2case/har.go b/hrp/internal/convert/har2case/har.go similarity index 100% rename from hrp/internal/har2case/har.go rename to hrp/internal/convert/har2case/har.go diff --git a/hrp/internal/postman2case/collection.go b/hrp/internal/convert/postman2case/collection.go similarity index 100% rename from hrp/internal/postman2case/collection.go rename to hrp/internal/convert/postman2case/collection.go diff --git a/hrp/internal/postman2case/core.go b/hrp/internal/convert/postman2case/core.go similarity index 72% rename from hrp/internal/postman2case/core.go rename to hrp/internal/convert/postman2case/core.go index f4b8b4e3..1f15cbf5 100644 --- a/hrp/internal/postman2case/core.go +++ b/hrp/internal/convert/postman2case/core.go @@ -3,7 +3,6 @@ package postman2case import ( "bytes" "fmt" - "github.com/httprunner/httprunner/v4/hrp/internal/json" "io" "mime/multipart" "net/url" @@ -17,6 +16,7 @@ import ( "github.com/httprunner/httprunner/v4/hrp" "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) const ( @@ -33,11 +33,18 @@ const ( ) const ( - suffixName = ".converted" + suffixName = ".converted" // distinguish the converted json(testcase) from the origin json(collection) extensionJSON = ".json" extensionYAML = ".yaml" ) +const ( + configProfile = "profile" + configPatch = "patch" + keyHeaders = "headers" + keyCookies = "cookies" +) + var contentTypeMap = map[string]string{ "text": "text/plain", "javascript": "application/javascript", @@ -54,9 +61,31 @@ func NewCollection(path string) *collection { type collection struct { path string + profile map[string]interface{} + patch map[string]interface{} outputDir string } +func (c *collection) SetProfile(path string) { + log.Info().Str("path", path).Msg("set profile") + c.profile = make(map[string]interface{}) + err := builtin.LoadFile(path, c.profile) + if err != nil { + log.Warn().Str("path", path). + Msg("invalid profile format, ignore!") + } +} + +func (c *collection) SetPatch(path string) { + log.Info().Str("path", path).Msg("set patch") + c.patch = make(map[string]interface{}) + err := builtin.LoadFile(path, c.patch) + if err != nil { + log.Warn().Str("path", path). + Msg("invalid patch format, ignore!") + } +} + func (c *collection) SetOutputDir(dir string) { log.Info().Str("dir", dir).Msg("set output directory") c.outputDir = dir @@ -169,10 +198,12 @@ func (c *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { Msg("convert teststep") step := &tStep{ - hrp.TStep{ + TStep: hrp.TStep{ Request: &hrp.Request{}, Validators: make([]interface{}, 0), }, + profile: c.profile, + patch: c.patch, } if err := step.makeRequestName(item); err != nil { return nil, err @@ -186,20 +217,22 @@ func (c *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { if err := step.makeRequestParams(item); err != nil { return nil, err } - if err := step.makeRequestHeadersAndCookies(item); err != nil { + 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 } - if err := step.makeValidate(item); err != nil { - return nil, err - } return &step.TStep, nil } type tStep struct { hrp.TStep + profile map[string]interface{} + patch map[string]interface{} } // makeRequestName indicates the step name the same as item name @@ -239,21 +272,89 @@ func (s *tStep) makeRequestParams(item *TItem) error { return nil } -func (s *tStep) makeRequestHeadersAndCookies(item *TItem) error { - s.Request.Headers = make(map[string]string) - for _, field := range item.Request.Headers { - if field.Disabled { - continue +func (s *tStep) updateRequestInfo(config string, key string) bool { + var m map[string]interface{} + switch config { + case configProfile: + m = s.profile + case configPatch: + m = s.patch + default: + return false + } + iRequestMap, existed := m[key] + if existed { + requestMap, ok := iRequestMap.(map[string]interface{}) + if ok { + for k, v := range requestMap { + switch key { + case keyHeaders: + s.Request.Headers[k] = fmt.Sprintf("%v", v) + case keyCookies: + s.Request.Cookies[k] = fmt.Sprintf("%v", v) + } + } + return true } - if strings.EqualFold(field.Key, "cookie") { - s.Request.Cookies[field.Key] = field.Value + log.Warn().Interface(key, iRequestMap).Msgf("%v from %v is not a map, ignore!", key, config) + } + return false +} + +func (s *tStep) makeRequestHeaders(item *TItem) error { + s.Request.Headers = make(map[string]string) + + // override all headers according to the profile + if s.updateRequestInfo(configProfile, keyHeaders) { + return nil + } + + // headers defined in postman collection + for _, field := range item.Request.Headers { + if field.Disabled || strings.EqualFold(field.Key, "cookie") { continue } s.Request.Headers[field.Key] = field.Value } + + // create or update the headers indicated in the patch + s.updateRequestInfo(configPatch, keyHeaders) return nil } +func (s *tStep) makeRequestCookies(item *TItem) error { + s.Request.Cookies = make(map[string]string) + + // override all cookies according to the profile + if s.updateRequestInfo(configProfile, keyCookies) { + return nil + } + + // cookies defined in postman collection + for _, field := range item.Request.Headers { + if field.Disabled || !strings.EqualFold(field.Key, "cookie") { + continue + } + s.parseRequestCookiesMap(field.Value) + } + + // create or update the cookies indicated in the patch + s.updateRequestInfo(configPatch, keyCookies) + return nil +} + +func (s *tStep) 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[0:index]] = cookie[index+1:] + } +} + func (s *tStep) makeRequestBody(item *TItem) error { mode := item.Request.Body.Mode if mode == "" { @@ -267,7 +368,7 @@ func (s *tStep) makeRequestBody(item *TItem) error { case enumBodyUrlEncoded: return s.makeRequestBodyUrlEncoded(item) case enumBodyFile, enumBodyGraphQL: - return errors.New("not supported body type") + return errors.Errorf("unsupported body type: %v", mode) } return nil } diff --git a/hrp/internal/convert/postman2case/core_test.go b/hrp/internal/convert/postman2case/core_test.go new file mode 100644 index 00000000..a102e136 --- /dev/null +++ b/hrp/internal/convert/postman2case/core_test.go @@ -0,0 +1,155 @@ +package postman2case + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + collectionPath = "../../../../examples/data/postman2case/demo.json" + profilePath = "../../../../examples/data/postman2case/profile.yml" + patchPath = "../../../../examples/data/postman2case/patch.yml" +) + +func TestGenJSON(t *testing.T) { + jsonPath, err := NewCollection(collectionPath).GenJSON() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fatal() + } +} + +func TestGenYAML(t *testing.T) { + yamlPath, err := NewCollection(collectionPath).GenYAML() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fatal() + } +} + +func TestLoadCollection(t *testing.T) { + tCollection, err := NewCollection(collectionPath).load() + if !assert.NoError(t, err) { + t.Fatal(err) + } + if !assert.Equal(t, "postman collection demo", tCollection.Info.Name) { + t.Fatal() + } +} + +func TestMakeTestCase(t *testing.T) { + tCase, err := NewCollection(collectionPath).makeTestCase() + 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 contains cookies) + // check headers + if !assert.Contains(t, tCase.TestSteps[1].Request.Headers["Content-Type"], "multipart/form-data") { + t.Fatal() + } + 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.NotEmpty(t, tCase.TestSteps[1].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() + } +} + +func TestMakeTestCaseWithProfile(t *testing.T) { + c := NewCollection(collectionPath) + c.SetProfile(profilePath) + tCase, err := c.makeTestCase() + if !assert.NoError(t, err) { + t.Fatal() + } + for _, step := range tCase.TestSteps { + if step.Request.Method == "GET" && !assert.Len(t, step.Request.Headers, 1) { + t.Fatal() + } + if step.Request.Method == "POST" && !assert.Len(t, step.Request.Headers, 2) { + t.Fatal() + } + if !assert.Equal(t, "all original headers will be overridden", step.Request.Headers["Header1"]) { + t.Fatal() + } + if !assert.Len(t, step.Request.Cookies, 1) { + t.Fatal() + } + if !assert.Equal(t, "all original cookies will be overridden", step.Request.Cookies["Cookie1"]) { + t.Fatal() + } + } +} + +func TestMakeTestCaseWithPatch(t *testing.T) { + c := NewCollection(collectionPath) + c.SetPatch(patchPath) + tCase, err := c.makeTestCase() + if !assert.NoError(t, err) { + t.Fatal() + } + // create cookies Cookie1 indicated in patch + if !assert.Equal(t, "this cookie will be created or updated", tCase.TestSteps[0].Request.Cookies["Cookie1"]) { + t.Fatal() + } + // update header User-Agent indicated in patch + if !assert.Equal(t, "this header will be created or updated", tCase.TestSteps[5].Request.Headers["User-Agent"]) { + t.Fatal() + } + // pass header Connection which is not indicated in patch + if !assert.Equal(t, "close", tCase.TestSteps[5].Request.Headers["Connection"]) { + t.Fatal() + } +} diff --git a/hrp/internal/postman2case/core_test.go b/hrp/internal/postman2case/core_test.go deleted file mode 100644 index 47b9eabc..00000000 --- a/hrp/internal/postman2case/core_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package postman2case - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -var collectionPath = "../../../examples/data/postman2case/postman_collection.json" - -func TestLoadPostmanCollection(t *testing.T) { - c, err := NewCollection(collectionPath).load() - if !assert.NoError(t, err) { - t.Fatal(err) - } - if !assert.Equal(t, "postman collection demo", c.Info.Name) { - t.Fatal() - } -} - -func TestGenJSON(t *testing.T) { - jsonPath, err := NewCollection(collectionPath).GenJSON() - if !assert.NoError(t, err) { - t.Fatal() - } - if !assert.NotEmpty(t, jsonPath) { - t.Fatal() - } -} - -func TestGenYAML(t *testing.T) { - yamlPath, err := NewCollection(collectionPath).GenYAML() - if !assert.NoError(t, err) { - t.Fatal() - } - if !assert.NotEmpty(t, yamlPath) { - t.Fatal() - } -} From ff9df1a251f6333b3542990ed1be332ac1968df2 Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Tue, 24 May 2022 13:36:34 +0800 Subject: [PATCH 03/14] refactor: hrp convert --- docs/CHANGELOG.md | 1 + docs/cmd/hrp.md | 4 +- docs/cmd/hrp_boom.md | 2 +- docs/cmd/hrp_convert.md | 14 +- docs/cmd/hrp_har2case.md | 2 +- docs/cmd/hrp_postman2case.md | 26 - docs/cmd/hrp_pytest.md | 2 +- docs/cmd/hrp_run.md | 2 +- docs/cmd/hrp_startproject.md | 2 +- .../har/{profile.yml => profile_override.yml} | 1 + examples/data/postman2case/patch.yml | 4 - examples/data/postman2case/profile.yml | 4 +- .../data/postman2case/profile_override.yml | 5 + go.mod | 1 + go.sum | 27 +- hrp/cmd/convert.go | 55 +- hrp/cmd/har2case.go | 7 - hrp/cmd/postman2case.go | 79 -- hrp/internal/builtin/utils.go | 9 +- hrp/internal/convert/README.md | 68 ++ hrp/internal/convert/asset/flowgram.svg | 1 + hrp/internal/convert/case2script/main.go | 120 --- hrp/internal/convert/converter.go | 374 +++++++++ hrp/internal/convert/converter_gotest.go | 60 ++ hrp/internal/convert/converter_har.go | 716 ++++++++++++++++++ hrp/internal/convert/converter_har_test.go | 373 +++++++++ hrp/internal/convert/converter_json.go | 111 +++ .../core.go => converter_postman.go} | 343 +++++---- ...core_test.go => converter_postman_test.go} | 46 +- hrp/internal/convert/converter_pytest.go | 19 + hrp/internal/convert/converter_yaml.go | 94 +++ hrp/internal/convert/har2case/core.go | 87 +-- hrp/internal/convert/har2case/core_test.go | 32 +- .../convert/postman2case/collection.go | 74 -- .../convert/{case2script => }/testcase.tmpl | 0 hrp/step_api.go | 2 +- hrp/testcase.go | 104 ++- 37 files changed, 2245 insertions(+), 626 deletions(-) delete mode 100644 docs/cmd/hrp_postman2case.md rename examples/data/har/{profile.yml => profile_override.yml} (86%) delete mode 100644 examples/data/postman2case/patch.yml create mode 100644 examples/data/postman2case/profile_override.yml delete mode 100644 hrp/cmd/postman2case.go create mode 100644 hrp/internal/convert/README.md create mode 100644 hrp/internal/convert/asset/flowgram.svg delete mode 100644 hrp/internal/convert/case2script/main.go create mode 100644 hrp/internal/convert/converter.go create mode 100644 hrp/internal/convert/converter_gotest.go create mode 100644 hrp/internal/convert/converter_har.go create mode 100644 hrp/internal/convert/converter_har_test.go create mode 100644 hrp/internal/convert/converter_json.go rename hrp/internal/convert/{postman2case/core.go => converter_postman.go} (52%) rename hrp/internal/convert/{postman2case/core_test.go => converter_postman_test.go} (73%) create mode 100644 hrp/internal/convert/converter_pytest.go create mode 100644 hrp/internal/convert/converter_yaml.go delete mode 100644 hrp/internal/convert/postman2case/collection.go rename hrp/internal/convert/{case2script => }/testcase.tmpl (100%) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 118ede44..4a06287a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ - fix: step request elapsed timing should contain ContentTransfer part - fix #1288: unable to go get httprunner v4 - feat: support converting Postman collection to HttpRunner testcase +- refactor: improve the extensibility of `hrp convert` using interface `ICaseConverter` **python version** diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index 2620d07f..578091e9 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -30,10 +30,10 @@ Copyright 2017 debugtalk ### SEE ALSO * [hrp boom](hrp_boom.md) - run load test with boomer -* [hrp convert](hrp_convert.md) - convert JSON/YAML testcases to pytest/gotest scripts +* [hrp convert](hrp_convert.md) - convert external cases to JSON/YAML/gotest/pytest testcases * [hrp har2case](hrp_har2case.md) - convert HAR to json/yaml testcase files * [hrp pytest](hrp_pytest.md) - run API test with pytest * [hrp run](hrp_run.md) - run API test with go engine * [hrp startproject](hrp_startproject.md) - create a scaffold project -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index ad27f7b2..37675fcc 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -41,4 +41,4 @@ hrp boom [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index 7390e9cc..d4771aad 100644 --- a/docs/cmd/hrp_convert.md +++ b/docs/cmd/hrp_convert.md @@ -1,6 +1,6 @@ ## hrp convert -convert JSON/YAML testcases to pytest/gotest scripts +convert external cases to JSON/YAML/gotest/pytest testcases ``` hrp convert $path... [flags] @@ -9,13 +9,17 @@ hrp convert $path... [flags] ### Options ``` - --gotest convert to gotest scripts (TODO) - -h, --help help for convert - --pytest convert to pytest scripts (default true) + -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) + --to-pytest convert to pytest scripts + --to-yaml convert to YAML scripts ``` ### SEE ALSO * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index db6b8b10..0ef151a3 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -24,4 +24,4 @@ hrp har2case $har_path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_postman2case.md b/docs/cmd/hrp_postman2case.md deleted file mode 100644 index 23c196e7..00000000 --- a/docs/cmd/hrp_postman2case.md +++ /dev/null @@ -1,26 +0,0 @@ -## hrp postman2case - -convert postman collection to json/yaml testcase files - -### Synopsis - -convert postman collection to json/yaml testcase files - -``` -hrp postman2case $postman_path... [flags] -``` - -### Options - -``` - -h, --help help for postman2case - -d, --output-dir string specify output directory, default to the same dir with postman collection file - -j, --to-json convert to JSON format (default true) - -y, --to-yaml convert to YAML format -``` - -### SEE ALSO - -* [hrp](hrp.md) - Next-Generation API Testing Solution. - -###### Auto generated by spf13/cobra on 12-May-2022 diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md index b2217ca1..2ed3b104 100644 --- a/docs/cmd/hrp_pytest.md +++ b/docs/cmd/hrp_pytest.md @@ -16,4 +16,4 @@ hrp pytest $path ... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index 6ffdd6d2..ff4ba4a7 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -35,4 +35,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index 4987cd6d..d598c7aa 100644 --- a/docs/cmd/hrp_startproject.md +++ b/docs/cmd/hrp_startproject.md @@ -20,4 +20,4 @@ hrp startproject $project_name [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 9-May-2022 +###### Auto generated by spf13/cobra on 23-May-2022 diff --git a/examples/data/har/profile.yml b/examples/data/har/profile_override.yml similarity index 86% rename from examples/data/har/profile.yml rename to examples/data/har/profile_override.yml index 69963ba2..35236a52 100644 --- a/examples/data/har/profile.yml +++ b/examples/data/har/profile_override.yml @@ -1,3 +1,4 @@ +override: true headers: Content-Type: "application/x-www-form-urlencoded" cookies: diff --git a/examples/data/postman2case/patch.yml b/examples/data/postman2case/patch.yml deleted file mode 100644 index c657b5ef..00000000 --- a/examples/data/postman2case/patch.yml +++ /dev/null @@ -1,4 +0,0 @@ -headers: - User-Agent: "this header will be created or updated" -cookies: - Cookie1: "this cookie will be created or updated" diff --git a/examples/data/postman2case/profile.yml b/examples/data/postman2case/profile.yml index 42e2e9f4..c657b5ef 100644 --- a/examples/data/postman2case/profile.yml +++ b/examples/data/postman2case/profile.yml @@ -1,4 +1,4 @@ headers: - Header1: "all original headers will be overridden" + User-Agent: "this header will be created or updated" cookies: - Cookie1: "all original cookies will be overridden" \ No newline at end of file + Cookie1: "this cookie will be created or updated" diff --git a/examples/data/postman2case/profile_override.yml b/examples/data/postman2case/profile_override.yml new file mode 100644 index 00000000..bc620e50 --- /dev/null +++ b/examples/data/postman2case/profile_override.yml @@ -0,0 +1,5 @@ +override: true +headers: + Header1: "all original headers will be overridden" +cookies: + Cookie1: "all original cookies will be overridden" \ No newline at end of file diff --git a/go.mod b/go.mod index 5dc2859b..ddf4db03 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/fatih/color v1.13.0 github.com/getsentry/sentry-go v0.13.0 + github.com/go-openapi/spec v0.20.6 github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.4.1 github.com/httprunner/funplugin v0.4.5 diff --git a/go.sum b/go.sum index 62502254..26432fc7 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,7 @@ github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -131,6 +132,16 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= @@ -262,6 +273,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -288,16 +301,20 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU= github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -348,6 +365,8 @@ github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5Vgl github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -837,8 +856,9 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -854,6 +874,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go index 48a9f4bc..31c536e4 100644 --- a/hrp/cmd/convert.go +++ b/hrp/cmd/convert.go @@ -2,48 +2,61 @@ package cmd import ( "errors" - "os" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/convert/case2script" + "github.com/httprunner/httprunner/v4/hrp/internal/convert" ) var convertCmd = &cobra.Command{ Use: "convert $path...", - Short: "convert JSON/YAML testcases to pytest/gotest scripts", - Args: cobra.ExactValidArgs(1), + Short: "convert external cases to JSON/YAML/gotest/pytest testcases", + Args: cobra.MinimumNArgs(1), PreRun: func(cmd *cobra.Command, args []string) { setLogLevel(logLevel) }, RunE: func(cmd *cobra.Command, args []string) error { - // TODO: integrate har2case, postman2case, etc. in convert command (forward compatibility) - if !pytestFlag && !gotestFlag { - return errors.New("please specify convertion type") + var flagCount int + var outputType convert.OutputType + if toJSONFlag { + flagCount++ } - - var err error - if gotestFlag { - err = case2script.Convert2TestScripts("gotest", args...) - } else { - err = case2script.Convert2TestScripts("pytest", args...) + if toYAMLFlag { + flagCount++ + outputType = convert.OutputTypeYAML } - if err != nil { - log.Error().Err(err).Msg("convert test scripts failed") - os.Exit(1) + if toGoTestFlag { + flagCount++ + outputType = convert.OutputTypeGoTest } + if toPyTestFlag { + flagCount++ + outputType = convert.OutputTypePyTest + } + if flagCount > 1 { + return errors.New("please specify at most one conversion flag") + } + iCaseConverters := convert.LoadConverters(outputType, outputDir, profilePath, args) + convert.Run(iCaseConverters) return nil }, } var ( - pytestFlag bool - gotestFlag bool + toJSONFlag bool + toYAMLFlag bool + toGoTestFlag bool + toPyTestFlag bool + outputDir string + profilePath string ) func init() { rootCmd.AddCommand(convertCmd) - convertCmd.Flags().BoolVar(&pytestFlag, "pytest", true, "convert to pytest scripts") - convertCmd.Flags().BoolVar(&gotestFlag, "gotest", false, "convert to gotest scripts (TODO)") + convertCmd.Flags().BoolVar(&toPyTestFlag, "to-pytest", false, "convert to pytest scripts") + convertCmd.Flags().BoolVar(&toGoTestFlag, "to-gotest", false, "convert to gotest scripts (TODO)") + convertCmd.Flags().BoolVar(&toJSONFlag, "to-json", false, "convert to JSON scripts (default)") + convertCmd.Flags().BoolVar(&toYAMLFlag, "to-yaml", false, "convert to YAML scripts") + convertCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") + convertCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers (except for auto-generated headers) and cookies") } diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go index 42fab1bd..d26fc4ff 100644 --- a/hrp/cmd/har2case.go +++ b/hrp/cmd/har2case.go @@ -40,11 +40,6 @@ var har2caseCmd = &cobra.Command{ har.SetProfile(har2caseProfilePath) } - // specify profile - if har2casePatchPath != "" { - har.SetPatch(har2casePatchPath) - } - // generate json/yaml files if har2caseGenYAMLFlag { outputPath, err = har.GenYAML() @@ -66,7 +61,6 @@ var ( har2caseGenYAMLFlag bool har2caseOutputDir string har2caseProfilePath string - har2casePatchPath string ) func init() { @@ -75,5 +69,4 @@ func init() { har2caseCmd.Flags().BoolVarP(&har2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") har2caseCmd.Flags().StringVarP(&har2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") har2caseCmd.Flags().StringVarP(&har2caseProfilePath, "profile", "p", "", "specify profile path to override headers and cookies") - har2caseCmd.Flags().StringVarP(&har2casePatchPath, "patch", "r", "", "specify the path of the file used to replace headers and cookies") } diff --git a/hrp/cmd/postman2case.go b/hrp/cmd/postman2case.go deleted file mode 100644 index 2e0c1369..00000000 --- a/hrp/cmd/postman2case.go +++ /dev/null @@ -1,79 +0,0 @@ -package cmd - -import ( - "errors" - - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - - "github.com/httprunner/httprunner/v4/hrp/internal/convert/postman2case" -) - -// postman2caseCmd represents the postman2case command -var postman2caseCmd = &cobra.Command{ - Use: "postman2case $postman_path...", - Short: "convert postman collection to json/yaml testcase files", - Long: `convert postman collection to json/yaml testcase files`, - Args: cobra.MinimumNArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { - setLogLevel(logLevel) - }, - RunE: func(cmd *cobra.Command, args []string) error { - var outputFiles []string - for _, arg := range args { - // must choose one - if !postman2caseGenJSONFlag && !postman2caseGenYAMLFlag { - return errors.New("please select convert format type") - } - var outputPath string - var err error - - collection := postman2case.NewCollection(arg) - - // specify output dir - if postman2caseOutputDir != "" { - collection.SetOutputDir(postman2caseOutputDir) - } - - // specify profile path - if postman2caseProfilePath != "" { - collection.SetProfile(postman2caseProfilePath) - } - - // specify patch path - if postman2casePatchPath != "" { - collection.SetPatch(postman2casePatchPath) - } - - // generate json/yaml files - if postman2caseGenYAMLFlag { - outputPath, err = collection.GenYAML() - } else { - outputPath, err = collection.GenJSON() // default - } - if err != nil { - return err - } - outputFiles = append(outputFiles, outputPath) - } - log.Info().Strs("output", outputFiles).Msg("convert testcase success") - return nil - }, -} - -var ( - postman2caseGenJSONFlag bool - postman2caseGenYAMLFlag bool - postman2caseOutputDir string - postman2caseProfilePath string - postman2casePatchPath string -) - -func init() { - rootCmd.AddCommand(postman2caseCmd) - postman2caseCmd.Flags().BoolVarP(&postman2caseGenJSONFlag, "to-json", "j", true, "convert to JSON format") - postman2caseCmd.Flags().BoolVarP(&postman2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") - postman2caseCmd.Flags().StringVarP(&postman2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with postman collection file") - postman2caseCmd.Flags().StringVarP(&postman2caseProfilePath, "profile", "p", "", "specify profile path to override original headers (except for Content-Type) and cookies") - postman2caseCmd.Flags().StringVarP(&postman2casePatchPath, "patch", "r", "", "specify patch path to create or update headers and cookies") -} diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go index cacad024..d32adfde 100644 --- a/hrp/internal/builtin/utils.go +++ b/hrp/internal/builtin/utils.go @@ -285,7 +285,8 @@ func LoadFile(path string, structObj interface{}) (err error) { if err != nil { return errors.Wrap(err, "read file failed") } - + // remove BOM at the beginning of file + file = bytes.Trim(file, "\xef\xbb\xbf") ext := filepath.Ext(path) switch ext { case ".json", ".har": @@ -351,3 +352,9 @@ func readFile(path string) ([]byte, error) { } return file, nil } + +func GetOutputNameWithoutExtension(path string) string { + base := filepath.Base(path) + ext := filepath.Ext(base) + return base[0:len(base)-len(ext)] + "_test" +} diff --git a/hrp/internal/convert/README.md b/hrp/internal/convert/README.md new file mode 100644 index 00000000..474c8c0e --- /dev/null +++ b/hrp/internal/convert/README.md @@ -0,0 +1,68 @@ +# hrp convert + +## 快速上手 +```shell +$ hrp convert -h +convert external cases 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 等格式的外部脚本转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化,输出的测试用例文件名格式为 `不带扩展名的原文件名称` + `_test` + `json/yaml/go/py` 后缀。 + +该指令的所有参数的详细介绍如下: + +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. 指定 `override` 为 `false/true` 可以选择 `profile` 的修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 `profile` 的默认修改模式为**替换**模式, +2. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎之间的差异(请求体、断言部分的格式略有不同),输出的 JSON/YAML 则统一采用 Golang 引擎的风格 + + +## 转换流程图 + +![flow chart](asset/flowgram.svg) + +## 开发进度 + +| from \ to | JSON | YAML | GoTest | PyTest | +|:---------:|:----:|:----:|:------:|:------:| +| HAR | ✅ | ✅ | ❌ | ✅ | +| Postman | ✅ | ✅ | ❌ | ✅ | +| JMeter | ❌ | ❌ | ❌ | ❌ | +| Swagger | ❌ | ❌ | ❌ | ❌ | +| JSON | ✅ | ✅ | ❌ | ✅ | +| YAML | ✅ | ✅ | ❌ | ✅ | +| GoTest | ❌ | ❌ | ❌ | ❌ | +| PyTest | ❌ | ❌ | ❌ | ❌ | \ No newline at end of file diff --git a/hrp/internal/convert/asset/flowgram.svg b/hrp/internal/convert/asset/flowgram.svg new file mode 100644 index 00000000..76652f6b --- /dev/null +++ b/hrp/internal/convert/asset/flowgram.svg @@ -0,0 +1 @@ +
HTTP 存档格式文件
(.har)
Postman 项目文件
(.json)
JMeter 项目文件
(.jmx)
gotest 测试用例
(.go)
pytest 测试用例
(.py)
JSON 测试用例
(.json)
YAML 测试用例
(.yaml)
Swagger 脚本文件
(.json / .yaml)
外部脚本文件
JSON/YAML 测试用例
代码形态测试用例
\ No newline at end of file diff --git a/hrp/internal/convert/case2script/main.go b/hrp/internal/convert/case2script/main.go deleted file mode 100644 index bfc75b27..00000000 --- a/hrp/internal/convert/case2script/main.go +++ /dev/null @@ -1,120 +0,0 @@ -package case2script - -import ( - _ "embed" - "fmt" - "os" - - "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" - "github.com/httprunner/httprunner/v4/hrp/internal/version" -) - -func Convert2TestScripts(destType string, paths ...string) error { - // report event - sdk.SendEvent(sdk.EventTracking{ - Category: "ConvertTests", - Action: fmt.Sprintf("hrp convert --%s", destType), - }) - - if destType == "gotest" { - return convert2GoTestScripts(paths...) - } else { - // default to pytest - return convert2PyTestScripts(paths...) - } -} - -func convert2PyTestScripts(paths ...string) error { - httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) - python3, err := builtin.EnsurePython3Venv(httprunner) - if err != nil { - return err - } - - args := append([]string{"-m", "httprunner", "make"}, paths...) - return builtin.ExecCommand(python3, args...) -} - -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 := CaseConverter{ - 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 - python3, err := builtin.EnsurePython3Venv("black") - if err != nil { - return err - } - args := append([]string{"-m", "black"}, pytestPaths...) - return builtin.ExecCommand(python3, args...) -} - -//go:embed testcase.tmpl -var testcaseTemplate string - -type CaseConverter struct { - *hrp.TCase -} - -func (c *CaseConverter) ToPyTest() (string, error) { - script := convertConfig(c.TCase.Config) - println(script) - return script, nil -} - -func (c *CaseConverter) ToGoTest() (string, error) { - return "", nil -} - -func convertConfig(config *hrp.TConfig) string { - script := fmt.Sprintf("Config('%s')", config.Name) - - if config.Variables != nil { - script += fmt.Sprintf(".variables(**{%v})", config.Variables) - } - if config.BaseURL != "" { - script += fmt.Sprintf(".base_url('%s')", config.BaseURL) - } - if config.Export != nil { - script += fmt.Sprintf(".export(*%v)", config.Export) - } - script += fmt.Sprintf(".verify(%v)", config.Verify) - - return script -} diff --git a/hrp/internal/convert/converter.go b/hrp/internal/convert/converter.go new file mode 100644 index 00000000..ac6831cc --- /dev/null +++ b/hrp/internal/convert/converter.go @@ -0,0 +1,374 @@ +package convert + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "reflect" + + "github.com/go-openapi/spec" + "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" +) + +const ( + suffixJSON = ".json" + suffixYAML = ".yaml" + suffixGoTest = ".go" + suffixPyTest = ".py" +) + +type InputType int + +const ( + InputTypeUnknown InputType = iota // default input type: unknown + InputTypeHAR + InputTypePostman + InputTypeSwagger + InputTypeJMeter + InputTypeJSON + InputTypeYAML + InputTypeGoTest + InputTypePyTest +) + +func (inputType InputType) String() string { + switch inputType { + case InputTypeHAR: + return "har" + case InputTypePostman: + return "postman" + case InputTypeSwagger: + return "swagger" + case InputTypeJMeter: + return "jmeter" + case InputTypeJSON: + return "json testcase" + case InputTypeYAML: + return "yaml testcase" + case InputTypeGoTest: + return "gotest script" + case InputTypePyTest: + return "pytest script" + default: + return "unknown" + } +} + +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" + } +} + +// TCaseConverter holds the common properties of case converter +type TCaseConverter struct { + InputPath string + OutputDir string + Profile *Profile + InputType InputType + OutputType OutputType + CaseHAR *CaseHar + CasePostman *CasePostman + CaseSwagger *spec.Swagger + TCase *hrp.TCase +} + +// 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 NewTCaseConverter(path string) (tCaseConverter *TCaseConverter) { + tCaseConverter = &TCaseConverter{ + InputPath: path, + InputType: InputTypeUnknown, + } + extName := filepath.Ext(path) + if extName == "" { + log.Warn().Msg("extension name should be specified") + return + } + var err error + switch extName { + case ".har": + caseHAR := new(CaseHar) + err = builtin.LoadFile(path, caseHAR) + if err == nil && !reflect.DeepEqual(*caseHAR, CaseHar{}) { + tCaseConverter.InputType = InputTypeHAR + tCaseConverter.CaseHAR = caseHAR + } + case ".json": + tCase := new(hrp.TCase) + err = builtin.LoadFile(path, tCase) + if err == nil && !reflect.DeepEqual(*tCase, hrp.TCase{}) { + tCaseConverter.InputType = InputTypeJSON + tCaseConverter.TCase = tCase + break + } + casePostman := new(CasePostman) + err = builtin.LoadFile(path, casePostman) + if err == nil && !reflect.DeepEqual(*casePostman, CasePostman{}) { + tCaseConverter.InputType = InputTypePostman + tCaseConverter.CasePostman = casePostman + break + } + caseSwagger := new(spec.Swagger) + err = builtin.LoadFile(path, caseSwagger) + if err == nil && !reflect.DeepEqual(*caseSwagger, spec.Swagger{}) { + tCaseConverter.InputType = InputTypeSwagger + tCaseConverter.CaseSwagger = caseSwagger + } + case ".yaml", ".yml": + tCase := new(hrp.TCase) + err = builtin.LoadFile(path, tCase) + if err == nil && !reflect.DeepEqual(*tCase, hrp.TCase{}) { + tCaseConverter.InputType = InputTypeYAML + tCaseConverter.TCase = tCase + break + } + caseSwagger := new(spec.Swagger) + err = builtin.LoadFile(path, caseSwagger) + if err == nil && !reflect.DeepEqual(*caseSwagger, spec.Swagger{}) { + tCaseConverter.InputType = InputTypeSwagger + tCaseConverter.CaseSwagger = caseSwagger + } + case ".go": // TODO + tCaseConverter.InputType = InputTypeGoTest + case ".py": // TODO + tCaseConverter.InputType = InputTypePyTest + case ".jmx": // TODO + tCaseConverter.InputType = InputTypeJMeter + default: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msgf("unsupported file type: %v", extName) + } + if tCaseConverter.InputType != InputTypeUnknown { + log.Info(). + Str("input path", tCaseConverter.InputPath). + Msgf("load case as: %s", tCaseConverter.InputType.String()) + } else { + log.Error().Err(err). + Str("input path", tCaseConverter.InputPath). + Msgf("failed to load case") + } + return +} + +func (c *TCaseConverter) SetProfile(path string) { + log.Info().Str("input path", c.InputPath).Str("profile", path).Msg("set profile") + profile := new(Profile) + err := builtin.LoadFile(path, profile) + if err != nil { + log.Warn().Str("path", path). + Msg("failed to load profile, ignore!") + return + } + c.Profile = profile +} + +func (c *TCaseConverter) SetOutputDir(dir string) { + log.Info().Str("input path", c.InputPath).Str("output directory", dir).Msg("set output directory") + c.OutputDir = dir +} + +func (c *TCaseConverter) genOutputPath(suffix string) string { + outFileFullName := builtin.GetOutputNameWithoutExtension(c.InputPath) + suffix + if c.OutputDir != "" { + return filepath.Join(c.OutputDir, outFileFullName) + } else { + return filepath.Join(filepath.Dir(c.InputPath), outFileFullName) + } + // TODO avoid outFileFullName conflict? +} + +func (c *TCaseConverter) ToPyTest() (string, error) { + script := convertConfig(c.TCase.Config) + println(script) + return script, nil +} + +func convertConfig(config *hrp.TConfig) string { + script := fmt.Sprintf("Config('%s')", config.Name) + + if config.Variables != nil { + script += fmt.Sprintf(".variables(**{%v})", config.Variables) + } + if config.BaseURL != "" { + script += fmt.Sprintf(".base_url('%s')", config.BaseURL) + } + if config.Export != nil { + script += fmt.Sprintf(".export(*%v)", config.Export) + } + script += fmt.Sprintf(".verify(%v)", config.Verify) + + return script +} + +func (c *TCaseConverter) ToGoTest() (string, error) { + return "", nil +} + +// ICaseConverter represents all kinds of case converters which could convert case into JSON/YAML/gotest/pytest format +type ICaseConverter interface { + Struct() *TCaseConverter + ToJSON() (string, error) + ToJSONTemp() (string, error) + ToYAML() (string, error) + ToGoTest() (string, error) + ToPyTest() (string, error) +} + +func LoadConverters(outputType OutputType, outputDir, profilePath string, args []string) []ICaseConverter { + // report event + sdk.SendEvent(sdk.EventTracking{ + Category: "ConvertTests", + Action: fmt.Sprintf("hrp convert --to-%s", outputType.String()), + }) + + var iCaseConverters []ICaseConverter + for _, arg := range args { + tCaseConverter := NewTCaseConverter(arg) + tCaseConverter.OutputType = outputType + if outputDir != "" { + tCaseConverter.SetOutputDir(outputDir) + } + if profilePath != "" { + tCaseConverter.SetProfile(profilePath) + } + switch tCaseConverter.InputType { + case InputTypeHAR: + iCaseConverters = append(iCaseConverters, NewConverterHAR(tCaseConverter)) + case InputTypePostman: + iCaseConverters = append(iCaseConverters, NewConverterPostman(tCaseConverter)) + case InputTypeJSON: + iCaseConverters = append(iCaseConverters, NewConverterJSON(tCaseConverter)) + case InputTypeYAML: + iCaseConverters = append(iCaseConverters, NewConverterYAML(tCaseConverter)) + case InputTypeSwagger, InputTypeJMeter, InputTypeGoTest, InputTypePyTest: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msg("case type not supported yet, ignore!") + default: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msg("unknown case type, ignore!") + } + } + return iCaseConverters +} + +func Run(iCaseConverters []ICaseConverter) { + var outputFiles []string + var err error + for _, iCaseConverter := range iCaseConverters { + log.Info().Str("input path", iCaseConverter.Struct().InputPath).Msg("start converting") + var outputFile string + switch iCaseConverter.Struct().OutputType { + case OutputTypeYAML: + outputFile, err = iCaseConverter.ToYAML() + case OutputTypeGoTest: + outputFile, err = iCaseConverter.ToGoTest() + case OutputTypePyTest: + outputFile, err = iCaseConverter.ToPyTest() + default: + outputFile, err = iCaseConverter.ToJSON() + } + if err != nil { + log.Error().Err(err). + Str("input path", iCaseConverter.Struct().InputPath). + Msg("error occurs during converting") + continue + } + outputFiles = append(outputFiles, outputFile) + } + log.Info().Strs("output files", outputFiles).Msg("conversion completed") +} + +func makeTestCaseFromJSONYAML(iCaseConverter ICaseConverter) (*hrp.TCase, error) { + tCase := iCaseConverter.Struct().TCase + if tCase == nil { + return nil, errors.Errorf("empty json/yaml testcase occurs") + } + profile := iCaseConverter.Struct().Profile + if profile == nil { + return tCase, nil + } + for _, step := range 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 tCase, nil +} + +func convertToPyTest(iCaseConverter ICaseConverter) (string, error) { + // convert to temporary json testcase compatible with python engine style + jsonPath, err := iCaseConverter.ToJSONTemp() + inputType := iCaseConverter.Struct().InputType + if err != nil { + return "", errors.Wrapf(err, "(%s -> pytest step 1) failed to convert to temporary json testcase", inputType.String()) + } + defer func() { + if jsonPath != "" { + if err = os.Remove(jsonPath); err != nil { + log.Error().Err(err).Msgf("(%s -> pytest step defer) failed to clean temporary json testcase", inputType.String()) + } + } + }() + + // convert from temporary json testcase to pytest + converterJSON := NewConverterJSON(NewTCaseConverter(jsonPath)) + pyTestPath, err := converterJSON.MakePyTestScript() + if err != nil { + return "", errors.Wrap(err, "(json -> pytest step 2) failed to convert from temporary json testcase to pytest ") + } + + // rename resultant pytest + renamedPyTestPath := iCaseConverter.Struct().genOutputPath(suffixPyTest) + err = os.Rename(pyTestPath, renamedPyTestPath) + if err != nil { + log.Error().Err(err).Msg("(json -> pytest step 3) failed to rename the resultant pytest file") + return pyTestPath, nil + } + return renamedPyTestPath, nil +} diff --git a/hrp/internal/convert/converter_gotest.go b/hrp/internal/convert/converter_gotest.go new file mode 100644 index 00000000..863da231 --- /dev/null +++ b/hrp/internal/convert/converter_gotest.go @@ -0,0 +1,60 @@ +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 + python3, err := builtin.EnsurePython3Venv("black") + if err != nil { + return err + } + args := append([]string{"-m", "black"}, pytestPaths...) + return builtin.ExecCommand(python3, args...) +} + +//go:embed testcase.tmpl +var testcaseTemplate string diff --git a/hrp/internal/convert/converter_har.go b/hrp/internal/convert/converter_har.go new file mode 100644 index 00000000..d34717c9 --- /dev/null +++ b/hrp/internal/convert/converter_har.go @@ -0,0 +1,716 @@ +package convert + +import ( + "encoding/base64" + "fmt" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "net/url" + "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/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 object for every HTTP request. +// In case when an HTTP trace tool isn't able to group requests by a page, +// the object is empty and individual requests doesn't have a parent page. +type Page struct { + /* There is one object for every exported web page and one + object for every HTTP request. In case when an HTTP trace tool isn't able to + group requests by a page, the 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 and 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 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 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 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 NewConverterHAR(converter *TCaseConverter) *ConverterHAR { + return &ConverterHAR{ + converter: converter, + } +} + +type ConverterHAR struct { + converter *TCaseConverter +} + +func (c *ConverterHAR) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterHAR) ToJSON() (string, error) { + tCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(tCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterHAR) ToJSONTemp() (string, error) { + tCase, err := c.makeTestCaseTemp() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(tCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterHAR) ToYAML() (string, error) { + tCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(tCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterHAR) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from har to gotest scripts is not supported yet") +} + +func (c *ConverterHAR) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterHAR) makeTestCase() (*hrp.TCase, error) { + teststeps, err := c.prepareTestSteps() + if err != nil { + return nil, err + } + + tCase := &hrp.TCase{ + Config: c.prepareConfig(), + TestSteps: teststeps, + } + err = tCase.MakeCompat2GoEngine() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterHAR) makeTestCaseTemp() (*hrp.TCase, error) { + teststeps, err := c.prepareTestSteps() + if err != nil { + return nil, err + } + + tCase := &hrp.TCase{ + Config: c.prepareConfig(), + TestSteps: teststeps, + } + err = tCase.MakeCompat2PyEngine() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterHAR) load() (*CaseHar, error) { + har := c.converter.CaseHAR + if har == nil { + return nil, errors.New("empty har case occurs") + } + return har, nil +} + +func (c *ConverterHAR) prepareConfig() *hrp.TConfig { + return hrp.NewConfig("testcase description"). + SetVerifySSL(false) +} + +func (c *ConverterHAR) prepareTestSteps() ([]*hrp.TStep, error) { + har, err := c.load() + if err != nil { + return nil, err + } + + var steps []*hrp.TStep + for _, entry := range har.Log.Entries { + step, err := c.prepareTestStep(&entry) + if err != nil { + return nil, err + } + steps = append(steps, step) + } + + return steps, nil +} + +func (c *ConverterHAR) 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), + }, + profile: c.converter.Profile, + } + 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 + profile *Profile +} + +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 + } + + if s.profile == nil { + return nil + } + // override all cookies according to the profile + if s.profile.Override { + s.Request.Cookies = make(map[string]string) + } + // create or update the cookies according to the profile + for k, v := range s.profile.Cookies { + s.Request.Cookies[k] = v + } + 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 + } + + if s.profile == nil { + return nil + } + // override all headers according to the profile + if s.profile.Override { + s.Request.Headers = make(map[string]string) + } + // create or update the headers according to the profile + for k, v := range s.profile.Headers { + s.Request.Headers[k] = v + } + 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 + var paramsList []string + for _, param := range entry.Request.PostData.Params { + paramsList = append(paramsList, fmt.Sprintf("%s=%s", param.Name, param.Value)) + } + s.Request.Body = strings.Join(paramsList, "&") + } else if strings.HasPrefix(mimeType, "text/plain") { + // post raw data + s.Request.Body = entry.Request.PostData.Text + } else { + // TODO + log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType) + } + return nil +} + +func (s *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 +} diff --git a/hrp/internal/convert/converter_har_test.go b/hrp/internal/convert/converter_har_test.go new file mode 100644 index 00000000..0d4daa11 --- /dev/null +++ b/hrp/internal/convert/converter_har_test.go @@ -0,0 +1,373 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/httprunner/httprunner/v4/hrp" +) + +var ( + harPath = "../../../examples/data/har/demo.har" + harPath2 = "../../../examples/data/har/postman-echo.har" + harProfileOverridePath = "../../../examples/data/har/profile_override.yml" +) + +var converterHAR = NewConverterHAR(NewTCaseConverter(harPath)) +var converterHAR2 = NewConverterHAR(NewTCaseConverter(harPath2)) + +func TestHAR2JSON(t *testing.T) { + jsonPath, err := converterHAR.ToJSON() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fatal() + } +} + +func TestHAR2YAML(t *testing.T) { + yamlPath, err := converterHAR2.ToYAML() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fatal() + } +} + +func TestLoadHAR(t *testing.T) { + h, err := converterHAR.load() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.Equal(t, "GET", h.Log.Entries[0].Request.Method) { + t.Fatal() + } + if !assert.Equal(t, "POST", h.Log.Entries[1].Request.Method) { + t.Fatal() + } +} + +func TestLoadHARWithProfile(t *testing.T) { + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) + _, err := h.load() + if !assert.NoError(t, err) { + t.Fatal() + } + + if !assert.Equal(t, + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + h.converter.Profile.Headers) { + t.Fatal() + } + if !assert.Equal(t, + map[string]string{"UserName": "debugtalk"}, + h.converter.Profile.Cookies) { + t.Fatal() + } +} + +func TestMakeTestCaseFromHAR(t *testing.T) { + tCase, err := converterHAR.makeTestCase() + 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, "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 := converterHAR.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 := converterHAR.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 TestMakeRequestHeadersWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) + entry := &Entry{ + Request: Request{ + Method: "POST", + Headers: []NVP{ + {Name: "Content-Type", Value: "application/json; charset=utf-8"}, + }, + }, + } + step, err := h.prepareTestStep(entry) + if !assert.NoError(t, err) { + t.Fatal() + } + + if !assert.Equal(t, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, 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 := converterHAR.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 TestMakeRequestCookiesWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) + entry := &Entry{ + Request: Request{ + Method: "POST", + Cookies: []Cookie{ + {Name: "abc", Value: "123"}, + {Name: "UserName", Value: "leolee"}, + }, + }, + } + step, err := h.prepareTestStep(entry) + if !assert.NoError(t, err) { + t.Fatal() + } + + if !assert.Equal(t, map[string]string{ + "UserName": "debugtalk", + }, 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 := converterHAR.prepareTestStep(entry) + if !assert.NoError(t, err) { + t.Fatal() + } + + if !assert.Equal(t, "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 := converterHAR.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 := converterHAR.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: Value:map[BlnResult:true]] + Text: "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=", + Encoding: "base64", + }, + }, + } + step, err := converterHAR.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() + } +} diff --git a/hrp/internal/convert/converter_json.go b/hrp/internal/convert/converter_json.go new file mode 100644 index 00000000..5aa0b69a --- /dev/null +++ b/hrp/internal/convert/converter_json.go @@ -0,0 +1,111 @@ +package convert + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" +) + +func NewConverterJSON(converter *TCaseConverter) *ConverterJSON { + return &ConverterJSON{ + converter: converter, + } +} + +type ConverterJSON struct { + converter *TCaseConverter +} + +func (c *ConverterJSON) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterJSON) ToJSON() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterJSON) ToJSONTemp() (string, error) { + testCase, err := c.makeTestCaseTemp() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterJSON) ToYAML() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterJSON) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from json testcase to gotest scripts is not supported yet") +} + +func (c *ConverterJSON) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterJSON) MakePyTestScript() (string, error) { + httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) + python3, err := builtin.EnsurePython3Venv(httprunner) + if err != nil { + return "", err + } + args := append([]string{"-m", "httprunner", "make"}, c.converter.InputPath) + err = builtin.ExecCommand(python3, args...) + if err != nil { + return "", err + } + return c.converter.genOutputPath(suffixPyTest), nil +} + +func (c *ConverterJSON) makeTestCase() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat2GoEngine() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterJSON) makeTestCaseTemp() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat2PyEngine() + if err != nil { + return nil, err + } + return tCase, nil +} diff --git a/hrp/internal/convert/postman2case/core.go b/hrp/internal/convert/converter_postman.go similarity index 52% rename from hrp/internal/convert/postman2case/core.go rename to hrp/internal/convert/converter_postman.go index 1f15cbf5..d373c23a 100644 --- a/hrp/internal/convert/postman2case/core.go +++ b/hrp/internal/convert/converter_postman.go @@ -1,4 +1,4 @@ -package postman2case +package convert import ( "bytes" @@ -19,6 +19,83 @@ import ( "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" @@ -32,19 +109,6 @@ const ( enumFieldTypeFile = "file" ) -const ( - suffixName = ".converted" // distinguish the converted json(testcase) from the origin json(collection) - extensionJSON = ".json" - extensionYAML = ".yaml" -) - -const ( - configProfile = "profile" - configPatch = "patch" - keyHeaders = "headers" - keyCookies = "cookies" -) - var contentTypeMap = map[string]string{ "text": "text/plain", "javascript": "application/javascript", @@ -53,119 +117,131 @@ var contentTypeMap = map[string]string{ "xml": "application/xml", } -func NewCollection(path string) *collection { - return &collection{ - path: path, +func NewConverterPostman(converter *TCaseConverter) *ConverterPostman { + return &ConverterPostman{ + converter: converter, } } -type collection struct { - path string - profile map[string]interface{} - patch map[string]interface{} - outputDir string +type ConverterPostman struct { + converter *TCaseConverter } -func (c *collection) SetProfile(path string) { - log.Info().Str("path", path).Msg("set profile") - c.profile = make(map[string]interface{}) - err := builtin.LoadFile(path, c.profile) - if err != nil { - log.Warn().Str("path", path). - Msg("invalid profile format, ignore!") - } +func (c *ConverterPostman) Struct() *TCaseConverter { + return c.converter } -func (c *collection) SetPatch(path string) { - log.Info().Str("path", path).Msg("set patch") - c.patch = make(map[string]interface{}) - err := builtin.LoadFile(path, c.patch) - if err != nil { - log.Warn().Str("path", path). - Msg("invalid patch format, ignore!") - } -} - -func (c *collection) SetOutputDir(dir string) { - log.Info().Str("dir", dir).Msg("set output directory") - c.outputDir = dir -} - -func (c *collection) GenJSON() (jsonPath string, err error) { +func (c *ConverterPostman) ToJSON() (string, error) { testCase, err := c.makeTestCase() if err != nil { return "", err } - jsonPath = c.genOutputPath(extensionJSON) + jsonPath := c.converter.genOutputPath(suffixJSON) err = builtin.Dump2JSON(testCase, jsonPath) - return + if err != nil { + return "", err + } + return jsonPath, nil } -func (c *collection) GenYAML() (yamlPath string, err error) { +func (c *ConverterPostman) ToJSONTemp() (string, error) { + testCase, err := c.makeTestCaseTemp() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterPostman) ToYAML() (string, error) { testCase, err := c.makeTestCase() if err != nil { return "", err } - yamlPath = c.genOutputPath(extensionYAML) + yamlPath := c.converter.genOutputPath(suffixYAML) err = builtin.Dump2YAML(testCase, yamlPath) - return -} - -func (c *collection) genOutputPath(suffix string) string { - file := getFilenameWithoutExtension(c.path) + suffix - if c.outputDir != "" { - return filepath.Join(c.outputDir, file) - } else { - return filepath.Join(filepath.Dir(c.path), file) + if err != nil { + return "", err } + return yamlPath, nil } -func getFilenameWithoutExtension(path string) string { - base := filepath.Base(path) - ext := filepath.Ext(base) - return base[0:len(base)-len(ext)] + suffixName +func (c *ConverterPostman) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from postman to gotest scripts is not supported yet") } -func (c *collection) makeTestCase() (*hrp.TCase, error) { - tCollection, err := c.load() +func (c *ConverterPostman) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterPostman) makeTestCase() (*hrp.TCase, error) { + casePostman, err := c.load() if err != nil { return nil, err } - teststeps, err := c.prepareTestSteps(tCollection) + teststeps, err := c.prepareTestSteps(casePostman) if err != nil { return nil, err } tCase := &hrp.TCase{ - Config: c.prepareConfig(tCollection), + Config: c.prepareConfig(casePostman), TestSteps: teststeps, } + err = tCase.MakeCompat2GoEngine() + if err != nil { + return nil, err + } return tCase, nil } -func (c *collection) load() (*TCollection, error) { - collection := &TCollection{} - err := builtin.LoadFile(c.path, collection) +func (c *ConverterPostman) makeTestCaseTemp() (*hrp.TCase, error) { + casePostman, err := c.load() if err != nil { - return nil, errors.Wrap(err, "load postman collection failed") + return nil, err } - return collection, nil + teststeps, err := c.prepareTestSteps(casePostman) + if err != nil { + return nil, err + } + tCase := &hrp.TCase{ + Config: c.prepareConfig(casePostman), + TestSteps: teststeps, + } + err = tCase.MakeCompat2PyEngine() + if err != nil { + return nil, err + } + return tCase, nil } -func (c *collection) prepareConfig(tCollection *TCollection) *hrp.TConfig { - return hrp.NewConfig(tCollection.Info.Name). +func (c *ConverterPostman) load() (*CasePostman, error) { + casePostman := c.converter.CasePostman + if casePostman == nil { + return nil, errors.New("empty postman case occurs") + } + return casePostman, nil +} + +func (c *ConverterPostman) prepareConfig(casePostman *CasePostman) *hrp.TConfig { + return hrp.NewConfig(casePostman.Info.Name). SetVerifySSL(false) } -func (c *collection) prepareTestSteps(tCollection *TCollection) ([]*hrp.TStep, error) { +func (c *ConverterPostman) prepareTestSteps(casePostman *CasePostman) ([]*hrp.TStep, error) { // recursively convert collection items into a list var itemList []TItem - for _, item := range tCollection.Items { + for _, item := range casePostman.Items { extractItemList(item, &itemList) } var steps []*hrp.TStep for _, item := range itemList { - step, err := c.prepareTestStep(&item) + step, err := c.prepareTestStep(&item, steps) if err != nil { return nil, err } @@ -191,19 +267,18 @@ func extractItemList(item TItem, itemList *[]TItem) { } } -func (c *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { +func (c *ConverterPostman) prepareTestStep(item *TItem, steps []*hrp.TStep) (*hrp.TStep, error) { log.Info(). Str("method", item.Request.Method). Str("url", item.Request.URL.Raw). Msg("convert teststep") - step := &tStep{ + step := &stepFromPostman{ TStep: hrp.TStep{ Request: &hrp.Request{}, Validators: make([]interface{}, 0), }, - profile: c.profile, - patch: c.patch, + profile: c.converter.Profile, } if err := step.makeRequestName(item); err != nil { return nil, err @@ -223,30 +298,29 @@ func (c *collection) prepareTestStep(item *TItem) (*hrp.TStep, error) { if err := step.makeRequestCookies(item); err != nil { return nil, err } - if err := step.makeRequestBody(item); err != nil { + if err := step.makeRequestBody(item, steps); err != nil { return nil, err } return &step.TStep, nil } -type tStep struct { +type stepFromPostman struct { hrp.TStep - profile map[string]interface{} - patch map[string]interface{} + profile *Profile } // makeRequestName indicates the step name the same as item name -func (s *tStep) makeRequestName(item *TItem) error { +func (s *stepFromPostman) makeRequestName(item *TItem) error { s.Name = item.Name return nil } -func (s *tStep) makeRequestMethod(item *TItem) error { +func (s *stepFromPostman) makeRequestMethod(item *TItem) error { s.Request.Method = hrp.HTTPMethod(item.Request.Method) return nil } -func (s *tStep) makeRequestURL(item *TItem) error { +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 { @@ -261,7 +335,7 @@ func (s *tStep) makeRequestURL(item *TItem) error { return nil } -func (s *tStep) makeRequestParams(item *TItem) error { +func (s *stepFromPostman) makeRequestParams(item *TItem) error { s.Request.Params = make(map[string]interface{}) for _, field := range item.Request.URL.Query { if field.Disabled { @@ -272,44 +346,9 @@ func (s *tStep) makeRequestParams(item *TItem) error { return nil } -func (s *tStep) updateRequestInfo(config string, key string) bool { - var m map[string]interface{} - switch config { - case configProfile: - m = s.profile - case configPatch: - m = s.patch - default: - return false - } - iRequestMap, existed := m[key] - if existed { - requestMap, ok := iRequestMap.(map[string]interface{}) - if ok { - for k, v := range requestMap { - switch key { - case keyHeaders: - s.Request.Headers[k] = fmt.Sprintf("%v", v) - case keyCookies: - s.Request.Cookies[k] = fmt.Sprintf("%v", v) - } - } - return true - } - log.Warn().Interface(key, iRequestMap).Msgf("%v from %v is not a map, ignore!", key, config) - } - return false -} - -func (s *tStep) makeRequestHeaders(item *TItem) error { - s.Request.Headers = make(map[string]string) - - // override all headers according to the profile - if s.updateRequestInfo(configProfile, keyHeaders) { - 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 @@ -317,20 +356,23 @@ func (s *tStep) makeRequestHeaders(item *TItem) error { s.Request.Headers[field.Key] = field.Value } - // create or update the headers indicated in the patch - s.updateRequestInfo(configPatch, keyHeaders) + if s.profile == nil { + return nil + } + // override all headers according to the profile + if s.profile.Override { + s.Request.Headers = make(map[string]string) + } + // create or update the headers according to the profile + for k, v := range s.profile.Headers { + s.Request.Headers[k] = v + } return nil } -func (s *tStep) makeRequestCookies(item *TItem) error { - s.Request.Cookies = make(map[string]string) - - // override all cookies according to the profile - if s.updateRequestInfo(configProfile, keyCookies) { - 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 @@ -338,12 +380,21 @@ func (s *tStep) makeRequestCookies(item *TItem) error { s.parseRequestCookiesMap(field.Value) } - // create or update the cookies indicated in the patch - s.updateRequestInfo(configPatch, keyCookies) + if s.profile == nil { + return nil + } + // override all cookies according to the profile + if s.profile.Override { + s.Request.Cookies = make(map[string]string) + } + // create or update the cookies according to the profile + for k, v := range s.profile.Cookies { + s.Request.Cookies[k] = v + } return nil } -func (s *tStep) parseRequestCookiesMap(cookies string) { +func (s *stepFromPostman) parseRequestCookiesMap(cookies string) { for _, cookie := range strings.Split(cookies, ";") { cookie = strings.TrimSpace(cookie) index := strings.Index(cookie, "=") @@ -351,11 +402,11 @@ func (s *tStep) parseRequestCookiesMap(cookies string) { log.Warn().Str("cookie", cookie).Msg("cookie format invalid") continue } - s.Request.Cookies[cookie[0:index]] = cookie[index+1:] + s.Request.Cookies[cookie[:index]] = cookie[index+1:] } } -func (s *tStep) makeRequestBody(item *TItem) error { +func (s *stepFromPostman) makeRequestBody(item *TItem, steps []*hrp.TStep) error { mode := item.Request.Body.Mode if mode == "" { return nil @@ -364,7 +415,7 @@ func (s *tStep) makeRequestBody(item *TItem) error { case enumBodyRaw: return s.makeRequestBodyRaw(item) case enumBodyFormData: - return s.makeRequestBodyFormData(item) + return s.makeRequestBodyFormData(item, steps) case enumBodyUrlEncoded: return s.makeRequestBodyUrlEncoded(item) case enumBodyFile, enumBodyGraphQL: @@ -373,7 +424,7 @@ func (s *tStep) makeRequestBody(item *TItem) error { return nil } -func (s *tStep) makeRequestBodyRaw(item *TItem) (err error) { +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) @@ -401,7 +452,7 @@ func (s *tStep) makeRequestBodyRaw(item *TItem) (err error) { return } -func (s *tStep) makeRequestBodyFormData(item *TItem) (err error) { +func (s *stepFromPostman) makeRequestBodyFormData(item *TItem, steps []*hrp.TStep) (err error) { defer func() { if err != nil { err = errors.Wrap(err, "make request body form-data failed") @@ -446,7 +497,7 @@ func writeFormDataFile(writer *multipart.Writer, field *TField) error { return err } -func (s *tStep) makeRequestBodyUrlEncoded(item *TItem) error { +func (s *stepFromPostman) makeRequestBodyUrlEncoded(item *TItem) error { payloadMap := make(map[string]string) for _, field := range item.Request.Body.URLEncoded { if field.Disabled { @@ -460,6 +511,6 @@ func (s *tStep) makeRequestBodyUrlEncoded(item *TItem) error { } // TODO makeValidate from example response -func (s *tStep) makeValidate(item *TItem) error { +func (s *stepFromPostman) makeValidate(item *TItem) error { return nil } diff --git a/hrp/internal/convert/postman2case/core_test.go b/hrp/internal/convert/converter_postman_test.go similarity index 73% rename from hrp/internal/convert/postman2case/core_test.go rename to hrp/internal/convert/converter_postman_test.go index a102e136..72994794 100644 --- a/hrp/internal/convert/postman2case/core_test.go +++ b/hrp/internal/convert/converter_postman_test.go @@ -1,4 +1,4 @@ -package postman2case +package convert import ( "testing" @@ -7,13 +7,15 @@ import ( ) var ( - collectionPath = "../../../../examples/data/postman2case/demo.json" - profilePath = "../../../../examples/data/postman2case/profile.yml" - patchPath = "../../../../examples/data/postman2case/patch.yml" + collectionPath = "../../../examples/data/postman2case/demo.json" + collectionProfileOverridePath = "../../../examples/data/postman2case/profile_override.yml" + collectionProfilePath = "../../../examples/data/postman2case/profile.yml" ) -func TestGenJSON(t *testing.T) { - jsonPath, err := NewCollection(collectionPath).GenJSON() +var converterPostman = NewConverterPostman(NewTCaseConverter(collectionPath)) + +func TestPostman2JSON(t *testing.T) { + jsonPath, err := converterPostman.ToJSON() if !assert.NoError(t, err) { t.Fatal() } @@ -22,8 +24,8 @@ func TestGenJSON(t *testing.T) { } } -func TestGenYAML(t *testing.T) { - yamlPath, err := NewCollection(collectionPath).GenYAML() +func TestPostman2YAML(t *testing.T) { + yamlPath, err := converterPostman.ToYAML() if !assert.NoError(t, err) { t.Fatal() } @@ -33,17 +35,17 @@ func TestGenYAML(t *testing.T) { } func TestLoadCollection(t *testing.T) { - tCollection, err := NewCollection(collectionPath).load() + casePostman, err := converterPostman.load() if !assert.NoError(t, err) { t.Fatal(err) } - if !assert.Equal(t, "postman collection demo", tCollection.Info.Name) { + if !assert.Equal(t, "postman collection demo", casePostman.Info.Name) { t.Fatal() } } -func TestMakeTestCase(t *testing.T) { - tCase, err := NewCollection(collectionPath).makeTestCase() +func TestMakeTestCaseFromCollection(t *testing.T) { + tCase, err := converterPostman.makeTestCase() if !assert.NoError(t, err) { t.Fatal() } @@ -107,9 +109,10 @@ func TestMakeTestCase(t *testing.T) { } } -func TestMakeTestCaseWithProfile(t *testing.T) { - c := NewCollection(collectionPath) - c.SetProfile(profilePath) +func TestMakeTestCaseWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(collectionPath) + tCaseConverter.SetProfile(collectionProfileOverridePath) + c := NewConverterPostman(tCaseConverter) tCase, err := c.makeTestCase() if !assert.NoError(t, err) { t.Fatal() @@ -133,22 +136,23 @@ func TestMakeTestCaseWithProfile(t *testing.T) { } } -func TestMakeTestCaseWithPatch(t *testing.T) { - c := NewCollection(collectionPath) - c.SetPatch(patchPath) +func TestMakeTestCaseWithProfile(t *testing.T) { + tCaseConverter := NewTCaseConverter(collectionPath) + tCaseConverter.SetProfile(collectionProfilePath) + c := NewConverterPostman(tCaseConverter) tCase, err := c.makeTestCase() if !assert.NoError(t, err) { t.Fatal() } - // create cookies Cookie1 indicated in patch + // create cookies Cookie1 indicated in profile if !assert.Equal(t, "this cookie will be created or updated", tCase.TestSteps[0].Request.Cookies["Cookie1"]) { t.Fatal() } - // update header User-Agent indicated in patch + // update header User-Agent indicated in profile if !assert.Equal(t, "this header will be created or updated", tCase.TestSteps[5].Request.Headers["User-Agent"]) { t.Fatal() } - // pass header Connection which is not indicated in patch + // pass header Connection which is not indicated in profile if !assert.Equal(t, "close", tCase.TestSteps[5].Request.Headers["Connection"]) { t.Fatal() } diff --git a/hrp/internal/convert/converter_pytest.go b/hrp/internal/convert/converter_pytest.go new file mode 100644 index 00000000..8c094900 --- /dev/null +++ b/hrp/internal/convert/converter_pytest.go @@ -0,0 +1,19 @@ +package convert + +import ( + "fmt" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" +) + +func convert2PyTestScripts(paths ...string) error { + httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) + python3, err := builtin.EnsurePython3Venv(httprunner) + if err != nil { + return err + } + + args := append([]string{"-m", "httprunner", "make"}, paths...) + return builtin.ExecCommand(python3, args...) +} diff --git a/hrp/internal/convert/converter_yaml.go b/hrp/internal/convert/converter_yaml.go new file mode 100644 index 00000000..81d1b1a9 --- /dev/null +++ b/hrp/internal/convert/converter_yaml.go @@ -0,0 +1,94 @@ +package convert + +import ( + "github.com/pkg/errors" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" +) + +func NewConverterYAML(converter *TCaseConverter) *ConverterYAML { + return &ConverterYAML{ + converter: converter, + } +} + +type ConverterYAML struct { + converter *TCaseConverter +} + +func (c *ConverterYAML) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterYAML) ToJSON() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterYAML) ToJSONTemp() (string, error) { + testCase, err := c.makeTestCaseTemp() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterYAML) ToYAML() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterYAML) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from yaml testcase to gotest scripts is not supported yet") +} + +func (c *ConverterYAML) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterYAML) makeTestCase() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat2GoEngine() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterYAML) makeTestCaseTemp() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat2PyEngine() + if err != nil { + return nil, err + } + return tCase, nil +} diff --git a/hrp/internal/convert/har2case/core.go b/hrp/internal/convert/har2case/core.go index 0e96a96d..25824855 100644 --- a/hrp/internal/convert/har2case/core.go +++ b/hrp/internal/convert/har2case/core.go @@ -22,13 +22,6 @@ const ( suffixYAML = ".yaml" ) -const ( - configProfile = "profile" - configPatch = "patch" - keyHeaders = "headers" - keyCookies = "cookies" -) - func NewHAR(path string) *har { return &har{ path: path, @@ -40,7 +33,6 @@ type har struct { filterStr string excludeStr string profile map[string]interface{} - patch map[string]interface{} outputDir string } @@ -54,16 +46,6 @@ func (h *har) SetProfile(path string) { } } -func (h *har) SetPatch(path string) { - log.Info().Str("path", path).Msg("set patch") - h.patch = make(map[string]interface{}) - err := builtin.LoadFile(path, h.patch) - if err != nil { - log.Warn().Str("path", path). - Msg("invalid patch format, ignore!") - } -} - func (h *har) SetOutputDir(dir string) { log.Info().Str("dir", dir).Msg("set output directory") h.outputDir = dir @@ -164,7 +146,6 @@ func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { Validators: make([]interface{}, 0), }, profile: h.profile, - patch: h.patch, } if err := step.makeRequestMethod(entry); err != nil { return nil, err @@ -193,7 +174,6 @@ func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { type tStep struct { hrp.TStep profile map[string]interface{} - patch map[string]interface{} } func (s *tStep) makeRequestMethod(entry *Entry) error { @@ -219,59 +199,43 @@ func (s *tStep) makeRequestParams(entry *Entry) error { return nil } -func (s *tStep) updateRequestInfo(config string, key string) bool { - var m map[string]interface{} - switch config { - case configProfile: - m = s.profile - case configPatch: - m = s.patch - default: - return false - } - iRequestMap, existed := m[key] - if existed { - requestMap, ok := iRequestMap.(map[string]interface{}) - if ok { - for k, v := range requestMap { - switch key { - case keyHeaders: - s.Request.Headers[k] = fmt.Sprintf("%v", v) - case keyCookies: - s.Request.Cookies[k] = fmt.Sprintf("%v", v) - } - } - return true - } - log.Warn().Interface(key, iRequestMap).Msgf("%v from %v is not a map, ignore!", key, config) - } - return false -} - func (s *tStep) makeRequestCookies(entry *Entry) error { s.Request.Cookies = make(map[string]string) - - // override all cookies according to the profile - if s.updateRequestInfo(configProfile, keyCookies) { - return nil + cookies, ok := s.profile["cookies"] + if ok { + // use cookies from profile + cookies, ok := cookies.(map[string]interface{}) + if ok { + for k, v := range cookies { + s.Request.Cookies[k] = fmt.Sprintf("%v", v) + } + return nil + } + log.Warn().Interface("cookies", cookies). + Msg("cookies from profile is not a map, ignore!") } // use cookies from har for _, cookie := range entry.Request.Cookies { s.Request.Cookies[cookie.Name] = cookie.Value } - - // create or update the cookies indicated in the patch - s.updateRequestInfo(configPatch, keyCookies) return nil } func (s *tStep) makeRequestHeaders(entry *Entry) error { s.Request.Headers = make(map[string]string) - - // override all headers according to the profile - if s.updateRequestInfo(configProfile, keyHeaders) { - return nil + headers, ok := s.profile["headers"] + if ok { + // use headers from profile + cookies, ok := headers.(map[string]interface{}) + if ok { + for k, v := range cookies { + s.Request.Headers[k] = fmt.Sprintf("%v", v) + } + return nil + } + log.Warn().Interface("headers", headers). + Msg("headers from profile is not a map, ignore!") } // use headers from har @@ -281,9 +245,6 @@ func (s *tStep) makeRequestHeaders(entry *Entry) error { } s.Request.Headers[header.Name] = header.Value } - - // create or update the headers indicated in the patch - s.updateRequestInfo(configPatch, keyHeaders) return nil } diff --git a/hrp/internal/convert/har2case/core_test.go b/hrp/internal/convert/har2case/core_test.go index ce6466fe..0fc6a3cb 100644 --- a/hrp/internal/convert/har2case/core_test.go +++ b/hrp/internal/convert/har2case/core_test.go @@ -1,7 +1,6 @@ package har2case import ( - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -12,7 +11,7 @@ import ( var ( harPath = "../../../../examples/data/har/demo.har" harPath2 = "../../../../examples/data/har/postman-echo.har" - profilePath = "../../../../examples/data/har/profile.yml" + profilePath = "../../../../examples/data/har/profile_override.yml" ) func TestGenJSON(t *testing.T) { @@ -382,32 +381,3 @@ func TestMakeValidate(t *testing.T) { t.Fatal() } } - -func Test_tStep_makeRequestCookies(t *testing.T) { - type fields struct { - TStep hrp.TStep - profile map[string]interface{} - patch map[string]interface{} - } - type args struct { - entry *Entry - } - tests := []struct { - name string - fields fields - args args - wantErr assert.ErrorAssertionFunc - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := &tStep{ - TStep: tt.fields.TStep, - profile: tt.fields.profile, - patch: tt.fields.patch, - } - tt.wantErr(t, s.makeRequestCookies(tt.args.entry), fmt.Sprintf("makeRequestCookies(%v)", tt.args.entry)) - }) - } -} diff --git a/hrp/internal/convert/postman2case/collection.go b/hrp/internal/convert/postman2case/collection.go deleted file mode 100644 index ddabee21..00000000 --- a/hrp/internal/convert/postman2case/collection.go +++ /dev/null @@ -1,74 +0,0 @@ -package postman2case - -/* -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 -*/ - -// TCollection represents the postman exported file -type TCollection 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"` -} diff --git a/hrp/internal/convert/case2script/testcase.tmpl b/hrp/internal/convert/testcase.tmpl similarity index 100% rename from hrp/internal/convert/case2script/testcase.tmpl rename to hrp/internal/convert/testcase.tmpl diff --git a/hrp/step_api.go b/hrp/step_api.go index 1c9992ba..7b57d896 100644 --- a/hrp/step_api.go +++ b/hrp/step_api.go @@ -47,7 +47,7 @@ func (path *APIPath) ToAPI() (*API, error) { if err != nil { return nil, err } - err = convertCompatValidator(api.Validators) + err = convertValidatorCompat2GoEngine(api.Validators) convertExtract(api.Extract) return api, err } diff --git a/hrp/testcase.go b/hrp/testcase.go index 05c2b051..d2c2bd80 100644 --- a/hrp/testcase.go +++ b/hrp/testcase.go @@ -64,7 +64,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { return nil, errors.New("incorrect testcase file format, expected config in file") } - err = tc.makeCompat() + err = tc.MakeCompat2GoEngine() if err != nil { return nil, err } @@ -154,27 +154,28 @@ type TCase struct { TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` } -// makeCompat converts TCase to compatible testcase -func (tc *TCase) makeCompat() error { - var err error +// MakeCompat2GoEngine converts TCase compatible with Golang engine style +func (tc *TCase) MakeCompat2GoEngine() (err error) { defer func() { if p := recover(); p != nil { - err = fmt.Errorf("convert compat testcase error: %v", p) + err = fmt.Errorf("[MakeCompat2GoEngine] convert compat testcase error: %v", p) } }() for _, step := range tc.TestSteps { - // 1. deal with request body compatible with HttpRunner + // 1. deal with request body compatibility if step.Request != nil && step.Request.Body == nil { if step.Request.Json != nil { step.Request.Headers["Content-Type"] = "application/json; charset=utf-8" step.Request.Body = step.Request.Json + step.Request.Json = nil } else if step.Request.Data != nil { step.Request.Body = step.Request.Data + step.Request.Data = nil } } - // 2. deal with validators compatible with HttpRunner - err = convertCompatValidator(step.Validators) + // 2. deal with validators compatibility + err = convertValidatorCompat2GoEngine(step.Validators) if err != nil { return err } @@ -185,16 +186,19 @@ func (tc *TCase) makeCompat() error { return nil } -func convertCompatValidator(Validators []interface{}) (err error) { +func convertValidatorCompat2GoEngine(Validators []interface{}) (err error) { for i, iValidator := range Validators { + if _, ok := iValidator.(Validator); ok { + continue + } validatorMap := iValidator.(map[string]interface{}) validator := Validator{} _, checkExisted := validatorMap["check"] _, assertExisted := validatorMap["assert"] _, expectExisted := validatorMap["expect"] - // check priority: HRP > HttpRunner + // validator check priority: Golang > Python engine style if checkExisted && assertExisted && expectExisted { - // HRP validator format + // Golang engine style validator.Check = validatorMap["check"].(string) validator.Assert = validatorMap["assert"].(string) validator.Expect = validatorMap["expect"] @@ -203,8 +207,10 @@ func convertCompatValidator(Validators []interface{}) (err error) { } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator - } else if len(validatorMap) == 1 { - // HttpRunner validator format + continue + } + if len(validatorMap) == 1 { + // Python engine style for assertMethod, iValidatorContent := range validatorMap { checkAndExpect := iValidatorContent.([]interface{}) if len(checkAndExpect) != 2 { @@ -216,9 +222,9 @@ func convertCompatValidator(Validators []interface{}) (err error) { } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator - } else { - return fmt.Errorf("unexpected validator format: %v", validatorMap) + continue } + return fmt.Errorf("unexpected validator format: %v", validatorMap) } return nil } @@ -244,6 +250,74 @@ func convertCheckExpr(checkExpr string) string { return strings.Join(checkItems, ".") } +// MakeCompat2PyEngine converts TCase compatible with Python engine style +func (tc *TCase) MakeCompat2PyEngine() (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("[MakeCompat2PyEngine] convert compat testcase error: %v", p) + } + }() + for _, step := range tc.TestSteps { + // 1. deal with request body compatibility + if step.Request != nil && step.Request.Body != nil { + if strings.HasPrefix(step.Request.Headers["Content-Type"], "application/json") { + step.Request.Json = step.Request.Body + step.Request.Body = nil + continue + } + step.Request.Data = step.Request.Body + step.Request.Body = nil + } + + // 2. deal with validators compatibility + err = convertValidatorCompat2PyEngine(step.Validators) + if err != nil { + return err + } + } + return +} + +func convertValidatorCompat2PyEngine(Validators []interface{}) (err error) { + for i, iValidator := range Validators { + if v, ok := iValidator.(Validator); ok { + var iValidatorContent []interface{} + iValidatorContent = append(iValidatorContent, v.Check) + iValidatorContent = append(iValidatorContent, v.Expect) + newValidatorMap := make(map[string]interface{}) + newValidatorMap[v.Assert] = iValidatorContent + Validators[i] = newValidatorMap + continue + } + validatorMap := iValidator.(map[string]interface{}) + // validator check priority: Python > Golang engine style + if len(validatorMap) == 1 { + // Python engine style + for _, iValidatorContent := range validatorMap { + checkAndExpect := iValidatorContent.([]interface{}) + if len(checkAndExpect) != 2 { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + } + continue + } + _, checkExisted := validatorMap["check"] + _, assertExisted := validatorMap["assert"] + _, expectExisted := validatorMap["expect"] + if checkExisted && assertExisted && expectExisted { + // Golang engine style + var iValidatorContent []interface{} + iValidatorContent = append(iValidatorContent, validatorMap["check"]) + iValidatorContent = append(iValidatorContent, validatorMap["expect"]) + newValidatorMap := make(map[string]interface{}) + newValidatorMap[validatorMap["assert"].(string)] = iValidatorContent + Validators[i] = newValidatorMap + continue + } + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + return +} func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) { testCases := make([]*TestCase, 0) From 957f49b3674add583f45805cd5172bfc495841be Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Tue, 24 May 2022 20:50:53 +0800 Subject: [PATCH 04/14] fix comment --- docs/cmd/hrp.md | 2 +- docs/cmd/hrp_convert.md | 2 +- .../postman_collection.json} | 0 .../{postman2case => postman}/profile.yml | 0 .../profile_override.yml | 0 hrp/cmd/convert.go | 5 +- hrp/cmd/har2case.go | 50 +-- hrp/internal/builtin/utils.go | 2 +- hrp/internal/convert/README.md | 28 +- hrp/internal/convert/asset/flowgram.png | Bin 0 -> 59725 bytes hrp/internal/convert/asset/flowgram.svg | 1 - hrp/internal/convert/converter.go | 23 +- hrp/internal/convert/converter_har.go | 2 +- .../convert/converter_postman_test.go | 6 +- hrp/internal/convert/har2case/README.md | 9 - hrp/internal/convert/har2case/core.go | 385 ------------------ hrp/internal/convert/har2case/core_test.go | 383 ----------------- hrp/internal/convert/har2case/har.go | 340 ---------------- hrp/testcase.go | 48 ++- 19 files changed, 82 insertions(+), 1204 deletions(-) rename examples/data/{postman2case/demo.json => postman/postman_collection.json} (100%) rename examples/data/{postman2case => postman}/profile.yml (100%) rename examples/data/{postman2case => postman}/profile_override.yml (100%) create mode 100644 hrp/internal/convert/asset/flowgram.png delete mode 100644 hrp/internal/convert/asset/flowgram.svg delete mode 100644 hrp/internal/convert/har2case/README.md delete mode 100644 hrp/internal/convert/har2case/core.go delete mode 100644 hrp/internal/convert/har2case/core_test.go delete mode 100644 hrp/internal/convert/har2case/har.go diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index 578091e9..9e7fe395 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -30,7 +30,7 @@ Copyright 2017 debugtalk ### SEE ALSO * [hrp boom](hrp_boom.md) - run load test with boomer -* [hrp convert](hrp_convert.md) - convert external cases to JSON/YAML/gotest/pytest testcases +* [hrp convert](hrp_convert.md) - convert to JSON/YAML/gotest/pytest testcases * [hrp har2case](hrp_har2case.md) - convert HAR to json/yaml testcase files * [hrp pytest](hrp_pytest.md) - run API test with pytest * [hrp run](hrp_run.md) - run API test with go engine diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index d4771aad..80fcaf2f 100644 --- a/docs/cmd/hrp_convert.md +++ b/docs/cmd/hrp_convert.md @@ -1,6 +1,6 @@ ## hrp convert -convert external cases to JSON/YAML/gotest/pytest testcases +convert to JSON/YAML/gotest/pytest testcases ``` hrp convert $path... [flags] diff --git a/examples/data/postman2case/demo.json b/examples/data/postman/postman_collection.json similarity index 100% rename from examples/data/postman2case/demo.json rename to examples/data/postman/postman_collection.json diff --git a/examples/data/postman2case/profile.yml b/examples/data/postman/profile.yml similarity index 100% rename from examples/data/postman2case/profile.yml rename to examples/data/postman/profile.yml diff --git a/examples/data/postman2case/profile_override.yml b/examples/data/postman/profile_override.yml similarity index 100% rename from examples/data/postman2case/profile_override.yml rename to examples/data/postman/profile_override.yml diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go index 31c536e4..a4c8d663 100644 --- a/hrp/cmd/convert.go +++ b/hrp/cmd/convert.go @@ -10,7 +10,7 @@ import ( var convertCmd = &cobra.Command{ Use: "convert $path...", - Short: "convert external cases to JSON/YAML/gotest/pytest testcases", + Short: "convert to JSON/YAML/gotest/pytest testcases", Args: cobra.MinimumNArgs(1), PreRun: func(cmd *cobra.Command, args []string) { setLogLevel(logLevel) @@ -36,8 +36,7 @@ var convertCmd = &cobra.Command{ if flagCount > 1 { return errors.New("please specify at most one conversion flag") } - iCaseConverters := convert.LoadConverters(outputType, outputDir, profilePath, args) - convert.Run(iCaseConverters) + convert.Run(outputType, outputDir, profilePath, args) return nil }, } diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go index d26fc4ff..9d5f2f10 100644 --- a/hrp/cmd/har2case.go +++ b/hrp/cmd/har2case.go @@ -3,10 +3,9 @@ package cmd import ( "errors" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/convert/har2case" + "github.com/httprunner/httprunner/v4/hrp/internal/convert" ) // har2caseCmd represents the har2case command @@ -19,39 +18,20 @@ var har2caseCmd = &cobra.Command{ setLogLevel(logLevel) }, RunE: func(cmd *cobra.Command, args []string) error { - var outputFiles []string - for _, arg := range args { - // must choose one - if !har2caseGenYAMLFlag && !har2caseGenJSONFlag { - return errors.New("please select convert format type") - } - var outputPath string - var err error - - har := har2case.NewHAR(arg) - - // specify output dir - if har2caseOutputDir != "" { - har.SetOutputDir(har2caseOutputDir) - } - - // specify profile - if har2caseProfilePath != "" { - har.SetProfile(har2caseProfilePath) - } - - // generate json/yaml files - if har2caseGenYAMLFlag { - outputPath, err = har.GenYAML() - } else { - outputPath, err = har.GenJSON() // default - } - if err != nil { - return err - } - outputFiles = append(outputFiles, outputPath) + var flagCount int + var har2caseOutputType convert.OutputType + if har2caseGenJSONFlag { + flagCount++ } - log.Info().Strs("output", outputFiles).Msg("convert testcase success") + if har2caseGenYAMLFlag { + flagCount++ + har2caseOutputType = convert.OutputTypeYAML + } + if flagCount > 1 { + return errors.New("please specify at most one conversion flag") + + } + convert.Run(har2caseOutputType, har2caseOutputDir, har2caseProfilePath, args) return nil }, } @@ -65,7 +45,7 @@ var ( func init() { rootCmd.AddCommand(har2caseCmd) - har2caseCmd.Flags().BoolVarP(&har2caseGenJSONFlag, "to-json", "j", true, "convert to JSON format") + har2caseCmd.Flags().BoolVarP(&har2caseGenJSONFlag, "to-json", "j", false, "convert to JSON format (default)") har2caseCmd.Flags().BoolVarP(&har2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") har2caseCmd.Flags().StringVarP(&har2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") har2caseCmd.Flags().StringVarP(&har2caseProfilePath, "profile", "p", "", "specify profile path to override headers and cookies") diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go index d32adfde..27098249 100644 --- a/hrp/internal/builtin/utils.go +++ b/hrp/internal/builtin/utils.go @@ -286,7 +286,7 @@ func LoadFile(path string, structObj interface{}) (err error) { return errors.Wrap(err, "read file failed") } // remove BOM at the beginning of file - file = bytes.Trim(file, "\xef\xbb\xbf") + file = bytes.TrimLeft(file, "\xef\xbb\xbf") ext := filepath.Ext(path) switch ext { case ".json", ".har": diff --git a/hrp/internal/convert/README.md b/hrp/internal/convert/README.md index 474c8c0e..d31381be 100644 --- a/hrp/internal/convert/README.md +++ b/hrp/internal/convert/README.md @@ -1,9 +1,10 @@ # hrp convert ## 快速上手 + ```shell $ hrp convert -h -convert external cases to JSON/YAML/gotest/pytest testcases +convert to JSON/YAML/gotest/pytest testcases Usage: hrp convert $path... [flags] @@ -21,22 +22,22 @@ Global Flags: --log-json set log to json format -l, --log-level string set log level (default "INFO") ``` + `hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 等格式的外部脚本转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化,输出的测试用例文件名格式为 `不带扩展名的原文件名称` + `_test` + `json/yaml/go/py` 后缀。 -该指令的所有参数的详细介绍如下: +该指令所有选项的详细说明如下: -1. `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入的外部脚本转化为对应形态的测试用例,四个参数中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例 +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` 信息 +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` 信息 +- 根据 profile 覆盖原有的 `Headers` 和 `Cookies` 信息 ```yaml override: true headers: @@ -46,22 +47,29 @@ cookies: ``` ## 注意事项 -1. 指定 `override` 为 `false/true` 可以选择 `profile` 的修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 `profile` 的默认修改模式为**替换**模式, -2. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎之间的差异(请求体、断言部分的格式略有不同),输出的 JSON/YAML 则统一采用 Golang 引擎的风格 + +1. `hrp convert` 可以自动识别输入类型,因此不需要通过选项来手动制定输入类型,如遇到无法识别、不支持或转换失败的情况,则会输出错误日志并跳过,不会影响其他转换过程的正常进行 +2. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式 +3. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格 ## 转换流程图 -![flow chart](asset/flowgram.svg) +`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 | ❌ | ❌ | ❌ | ❌ | diff --git a/hrp/internal/convert/asset/flowgram.png b/hrp/internal/convert/asset/flowgram.png new file mode 100644 index 0000000000000000000000000000000000000000..3e676ec718bca2e95130b5df9f145921d8a76784 GIT binary patch literal 59725 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 literal 0 HcmV?d00001 diff --git a/hrp/internal/convert/asset/flowgram.svg b/hrp/internal/convert/asset/flowgram.svg deleted file mode 100644 index 76652f6b..00000000 --- a/hrp/internal/convert/asset/flowgram.svg +++ /dev/null @@ -1 +0,0 @@ -
HTTP 存档格式文件
(.har)
Postman 项目文件
(.json)
JMeter 项目文件
(.jmx)
gotest 测试用例
(.go)
pytest 测试用例
(.py)
JSON 测试用例
(.json)
YAML 测试用例
(.yaml)
Swagger 脚本文件
(.json / .yaml)
外部脚本文件
JSON/YAML 测试用例
代码形态测试用例
\ No newline at end of file diff --git a/hrp/internal/convert/converter.go b/hrp/internal/convert/converter.go index ac6831cc..8733614c 100644 --- a/hrp/internal/convert/converter.go +++ b/hrp/internal/convert/converter.go @@ -117,42 +117,46 @@ func NewTCaseConverter(path string) (tCaseConverter *TCaseConverter) { case ".har": caseHAR := new(CaseHar) err = builtin.LoadFile(path, caseHAR) - if err == nil && !reflect.DeepEqual(*caseHAR, CaseHar{}) { + if err == nil && !reflect.ValueOf(*caseHAR).IsZero() { tCaseConverter.InputType = InputTypeHAR tCaseConverter.CaseHAR = caseHAR } case ".json": tCase := new(hrp.TCase) err = builtin.LoadFile(path, tCase) - if err == nil && !reflect.DeepEqual(*tCase, hrp.TCase{}) { + if err == nil && !reflect.ValueOf(*tCase).IsZero() { tCaseConverter.InputType = InputTypeJSON tCaseConverter.TCase = tCase break } casePostman := new(CasePostman) err = builtin.LoadFile(path, casePostman) - if err == nil && !reflect.DeepEqual(*casePostman, CasePostman{}) { + // deal with postman field name conflict with swagger + descriptionBackup := casePostman.Info.Description + casePostman.Info.Description = "" + if err == nil && !reflect.ValueOf(*casePostman).IsZero() { tCaseConverter.InputType = InputTypePostman + casePostman.Info.Description = descriptionBackup tCaseConverter.CasePostman = casePostman break } caseSwagger := new(spec.Swagger) err = builtin.LoadFile(path, caseSwagger) - if err == nil && !reflect.DeepEqual(*caseSwagger, spec.Swagger{}) { + if err == nil && !reflect.ValueOf(*caseSwagger).IsZero() { tCaseConverter.InputType = InputTypeSwagger tCaseConverter.CaseSwagger = caseSwagger } case ".yaml", ".yml": tCase := new(hrp.TCase) err = builtin.LoadFile(path, tCase) - if err == nil && !reflect.DeepEqual(*tCase, hrp.TCase{}) { + if err == nil && !reflect.ValueOf(*tCase).IsZero() { tCaseConverter.InputType = InputTypeYAML tCaseConverter.TCase = tCase break } caseSwagger := new(spec.Swagger) err = builtin.LoadFile(path, caseSwagger) - if err == nil && !reflect.DeepEqual(*caseSwagger, spec.Swagger{}) { + if err == nil && !reflect.ValueOf(*caseSwagger).IsZero() { tCaseConverter.InputType = InputTypeSwagger tCaseConverter.CaseSwagger = caseSwagger } @@ -243,13 +247,14 @@ type ICaseConverter interface { ToPyTest() (string, error) } -func LoadConverters(outputType OutputType, outputDir, profilePath string, args []string) []ICaseConverter { +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()), }) + // identify input and load converters var iCaseConverters []ICaseConverter for _, arg := range args { tCaseConverter := NewTCaseConverter(arg) @@ -279,10 +284,8 @@ func LoadConverters(outputType OutputType, outputDir, profilePath string, args [ Msg("unknown case type, ignore!") } } - return iCaseConverters -} -func Run(iCaseConverters []ICaseConverter) { + // start converting var outputFiles []string var err error for _, iCaseConverter := range iCaseConverters { diff --git a/hrp/internal/convert/converter_har.go b/hrp/internal/convert/converter_har.go index d34717c9..1ee513ae 100644 --- a/hrp/internal/convert/converter_har.go +++ b/hrp/internal/convert/converter_har.go @@ -3,7 +3,6 @@ package convert import ( "encoding/base64" "fmt" - "github.com/httprunner/httprunner/v4/hrp/internal/builtin" "net/url" "sort" "strings" @@ -13,6 +12,7 @@ import ( "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" ) diff --git a/hrp/internal/convert/converter_postman_test.go b/hrp/internal/convert/converter_postman_test.go index 72994794..9e8ad126 100644 --- a/hrp/internal/convert/converter_postman_test.go +++ b/hrp/internal/convert/converter_postman_test.go @@ -7,9 +7,9 @@ import ( ) var ( - collectionPath = "../../../examples/data/postman2case/demo.json" - collectionProfileOverridePath = "../../../examples/data/postman2case/profile_override.yml" - collectionProfilePath = "../../../examples/data/postman2case/profile.yml" + collectionPath = "../../../examples/data/postman/postman_collection.json" + collectionProfileOverridePath = "../../../examples/data/postman/profile_override.yml" + collectionProfilePath = "../../../examples/data/postman/profile.yml" ) var converterPostman = NewConverterPostman(NewTCaseConverter(collectionPath)) diff --git a/hrp/internal/convert/har2case/README.md b/hrp/internal/convert/har2case/README.md deleted file mode 100644 index 08c0b4dc..00000000 --- a/hrp/internal/convert/har2case/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# har2case - -Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner and HttpRunner+. - -## Install - -## Quick Start - -## Examples diff --git a/hrp/internal/convert/har2case/core.go b/hrp/internal/convert/har2case/core.go deleted file mode 100644 index 25824855..00000000 --- a/hrp/internal/convert/har2case/core.go +++ /dev/null @@ -1,385 +0,0 @@ -package har2case - -import ( - "encoding/base64" - "fmt" - "net/url" - "path/filepath" - "sort" - "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" - "github.com/httprunner/httprunner/v4/hrp/internal/sdk" -) - -const ( - suffixJSON = ".json" - suffixYAML = ".yaml" -) - -func NewHAR(path string) *har { - return &har{ - path: path, - } -} - -type har struct { - path string - filterStr string - excludeStr string - profile map[string]interface{} - outputDir string -} - -func (h *har) SetProfile(path string) { - log.Info().Str("path", path).Msg("set profile") - h.profile = make(map[string]interface{}) - err := builtin.LoadFile(path, h.profile) - if err != nil { - log.Warn().Str("path", path). - Msg("invalid profile format, ignore!") - } -} - -func (h *har) SetOutputDir(dir string) { - log.Info().Str("dir", dir).Msg("set output directory") - h.outputDir = dir -} - -func (h *har) GenJSON() (jsonPath string, err error) { - event := sdk.EventTracking{ - Category: "ConvertTests", - Action: "hrp har2case --to-json", - } - // report start event - go sdk.SendEvent(event) - // report running timing event - defer sdk.SendEvent(event.StartTiming("execution")) - - tCase, err := h.makeTestCase() - if err != nil { - return "", err - } - jsonPath = h.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(tCase, jsonPath) - return -} - -func (h *har) GenYAML() (yamlPath string, err error) { - event := sdk.EventTracking{ - Category: "ConvertTests", - Action: "hrp har2case --to-yaml", - } - // report start event - go sdk.SendEvent(event) - // report running timing event - defer sdk.SendEvent(event.StartTiming("execution")) - - tCase, err := h.makeTestCase() - if err != nil { - return "", err - } - yamlPath = h.genOutputPath(suffixYAML) - err = builtin.Dump2YAML(tCase, yamlPath) - return -} - -func (h *har) makeTestCase() (*hrp.TCase, error) { - teststeps, err := h.prepareTestSteps() - if err != nil { - return nil, err - } - - tCase := &hrp.TCase{ - Config: h.prepareConfig(), - TestSteps: teststeps, - } - return tCase, nil -} - -func (h *har) load() (*Har, error) { - har := &Har{} - err := builtin.LoadFile(h.path, har) - if err != nil { - return nil, errors.Wrap(err, "load har failed") - } - return har, nil -} - -func (h *har) prepareConfig() *hrp.TConfig { - return hrp.NewConfig("testcase description"). - SetVerifySSL(false) -} - -func (h *har) prepareTestSteps() ([]*hrp.TStep, error) { - har, err := h.load() - if err != nil { - return nil, err - } - - var steps []*hrp.TStep - for _, entry := range har.Log.Entries { - step, err := h.prepareTestStep(&entry) - if err != nil { - return nil, err - } - steps = append(steps, step) - } - - return steps, nil -} - -func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { - log.Info(). - Str("method", entry.Request.Method). - Str("url", entry.Request.URL). - Msg("convert teststep") - - step := &tStep{ - TStep: hrp.TStep{ - Request: &hrp.Request{}, - Validators: make([]interface{}, 0), - }, - profile: h.profile, - } - if err := step.makeRequestMethod(entry); err != nil { - return nil, err - } - if err := step.makeRequestURL(entry); err != nil { - return nil, err - } - if err := step.makeRequestParams(entry); err != nil { - return nil, err - } - if err := step.makeRequestCookies(entry); err != nil { - return nil, err - } - if err := step.makeRequestHeaders(entry); err != nil { - return nil, err - } - if err := step.makeRequestBody(entry); err != nil { - return nil, err - } - if err := step.makeValidate(entry); err != nil { - return nil, err - } - return &step.TStep, nil -} - -type tStep struct { - hrp.TStep - profile map[string]interface{} -} - -func (s *tStep) makeRequestMethod(entry *Entry) error { - s.Request.Method = hrp.HTTPMethod(entry.Request.Method) - return nil -} - -func (s *tStep) makeRequestURL(entry *Entry) error { - u, err := url.Parse(entry.Request.URL) - if err != nil { - log.Error().Err(err).Msg("make request url failed") - return err - } - s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path) - return nil -} - -func (s *tStep) makeRequestParams(entry *Entry) error { - s.Request.Params = make(map[string]interface{}) - for _, param := range entry.Request.QueryString { - s.Request.Params[param.Name] = param.Value - } - return nil -} - -func (s *tStep) makeRequestCookies(entry *Entry) error { - s.Request.Cookies = make(map[string]string) - cookies, ok := s.profile["cookies"] - if ok { - // use cookies from profile - cookies, ok := cookies.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Cookies[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("cookies", cookies). - Msg("cookies from profile is not a map, ignore!") - } - - // use cookies from har - for _, cookie := range entry.Request.Cookies { - s.Request.Cookies[cookie.Name] = cookie.Value - } - return nil -} - -func (s *tStep) makeRequestHeaders(entry *Entry) error { - s.Request.Headers = make(map[string]string) - headers, ok := s.profile["headers"] - if ok { - // use headers from profile - cookies, ok := headers.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Headers[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("headers", headers). - Msg("headers from profile is not a map, ignore!") - } - - // use headers from har - for _, header := range entry.Request.Headers { - if strings.EqualFold(header.Name, "cookie") { - continue - } - s.Request.Headers[header.Name] = header.Value - } - return nil -} - -func (s *tStep) makeRequestBody(entry *Entry) error { - mimeType := entry.Request.PostData.MimeType - if mimeType == "" { - // GET/HEAD/DELETE without body - return nil - } - - // POST/PUT with body - if strings.HasPrefix(mimeType, "application/json") { - // post json - var body interface{} - 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 - var paramsList []string - for _, param := range entry.Request.PostData.Params { - paramsList = append(paramsList, fmt.Sprintf("%s=%s", param.Name, param.Value)) - } - s.Request.Body = strings.Join(paramsList, "&") - } else if strings.HasPrefix(mimeType, "text/plain") { - // post raw data - s.Request.Body = entry.Request.PostData.Text - } else { - // TODO - log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType) - } - return nil -} - -func (s *tStep) makeValidate(entry *Entry) error { - // make validator for response status code - s.Validators = append(s.Validators, hrp.Validator{ - Check: "status_code", - Assert: "equals", - Expect: entry.Response.Status, - Message: "assert response status code", - }) - - // make validators for response headers - for _, header := range entry.Response.Headers { - // assert Content-Type - if strings.EqualFold(header.Name, "Content-Type") { - s.Validators = append(s.Validators, hrp.Validator{ - Check: "headers.\"Content-Type\"", - Assert: "equals", - Expect: header.Value, - Message: "assert response header Content-Type", - }) - } - } - - // make validators for response body - respBody := entry.Response.Content - if respBody.Text == "" { - // response body is empty - return nil - } - if strings.HasPrefix(respBody.MimeType, "application/json") { - var data []byte - var err error - // response body is json - if respBody.Encoding == "base64" { - // decode base64 text - data, err = base64.StdEncoding.DecodeString(respBody.Text) - if err != nil { - return errors.Wrap(err, "decode base64 error") - } - } else if respBody.Encoding == "" { - // no encoding - data = []byte(respBody.Text) - } else { - // other encoding type - return nil - } - // convert to json - var body interface{} - if err = json.Unmarshal(data, &body); err != nil { - return errors.Wrap(err, "json.Unmarshal body error") - } - jsonBody, ok := body.(map[string]interface{}) - if !ok { - return fmt.Errorf("response body is not json, not matched with MimeType") - } - - // response body is json - keys := make([]string, 0, len(jsonBody)) - for k := range jsonBody { - keys = append(keys, k) - } - // sort map keys to keep validators in stable order - sort.Strings(keys) - for _, key := range keys { - value := jsonBody[key] - switch v := value.(type) { - case map[string]interface{}: - continue - case []interface{}: - continue - default: - s.Validators = append(s.Validators, hrp.Validator{ - Check: fmt.Sprintf("body.%s", key), - Assert: "equals", - Expect: v, - Message: fmt.Sprintf("assert response body %s", key), - }) - } - } - } - - return nil -} - -func (h *har) genOutputPath(suffix string) string { - file := getFilenameWithoutExtension(h.path) + suffix - if h.outputDir != "" { - return filepath.Join(h.outputDir, file) - } else { - return filepath.Join(filepath.Dir(h.path), file) - } -} - -func getFilenameWithoutExtension(path string) string { - base := filepath.Base(path) - ext := filepath.Ext(base) - return base[0 : len(base)-len(ext)] -} diff --git a/hrp/internal/convert/har2case/core_test.go b/hrp/internal/convert/har2case/core_test.go deleted file mode 100644 index 0fc6a3cb..00000000 --- a/hrp/internal/convert/har2case/core_test.go +++ /dev/null @@ -1,383 +0,0 @@ -package har2case - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/httprunner/httprunner/v4/hrp" -) - -var ( - harPath = "../../../../examples/data/har/demo.har" - harPath2 = "../../../../examples/data/har/postman-echo.har" - profilePath = "../../../../examples/data/har/profile_override.yml" -) - -func TestGenJSON(t *testing.T) { - jsonPath, err := NewHAR(harPath).GenJSON() - if !assert.NoError(t, err) { - t.Fatal() - } - if !assert.NotEmpty(t, jsonPath) { - t.Fatal() - } -} - -func TestGenYAML(t *testing.T) { - yamlPath, err := NewHAR(harPath2).GenYAML() - if !assert.NoError(t, err) { - t.Fatal() - } - if !assert.NotEmpty(t, yamlPath) { - t.Fatal() - } -} - -func TestLoadHAR(t *testing.T) { - har := NewHAR(harPath) - h, err := har.load() - if !assert.NoError(t, err) { - t.Fatal() - } - if !assert.Equal(t, "GET", h.Log.Entries[0].Request.Method) { - t.Fatal() - } - if !assert.Equal(t, "POST", h.Log.Entries[1].Request.Method) { - t.Fatal() - } -} - -func TestLoadHARWithProfile(t *testing.T) { - har := NewHAR(harPath) - har.SetProfile(profilePath) - _, err := har.load() - if !assert.NoError(t, err) { - t.Fatal() - } - - if !assert.Equal(t, - map[string]interface{}{"Content-Type": "application/x-www-form-urlencoded"}, - har.profile["headers"]) { - t.Fatal() - } - if !assert.Equal(t, - map[string]interface{}{"UserName": "debugtalk"}, - har.profile["cookies"]) { - t.Fatal() - } -} - -func TestMakeTestCase(t *testing.T) { - har := NewHAR(harPath) - tCase, err := har.makeTestCase() - 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, "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 TestGetFilenameWithoutExtension(t *testing.T) { - filename := getFilenameWithoutExtension(harPath2) - if !assert.Equal(t, "postman-echo", filename) { - t.Fatal() - } -} - -func TestMakeRequestURL(t *testing.T) { - har := NewHAR("") - entry := &Entry{ - Request: Request{ - URL: "http://127.0.0.1:8080/api/login", - }, - } - step, err := har.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) { - har := NewHAR("") - entry := &Entry{ - Request: Request{ - Method: "POST", - Headers: []NVP{ - {Name: "Content-Type", Value: "application/json; charset=utf-8"}, - }, - }, - } - step, err := har.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 TestMakeRequestHeadersWithProfile(t *testing.T) { - har := NewHAR("") - har.SetProfile(profilePath) - entry := &Entry{ - Request: Request{ - Method: "POST", - Headers: []NVP{ - {Name: "Content-Type", Value: "application/json; charset=utf-8"}, - }, - }, - } - step, err := har.prepareTestStep(entry) - if !assert.NoError(t, err) { - t.Fatal() - } - - if !assert.Equal(t, map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - }, step.Request.Headers) { - t.Fatal() - } -} - -func TestMakeRequestCookies(t *testing.T) { - har := NewHAR("") - entry := &Entry{ - Request: Request{ - Method: "POST", - Cookies: []Cookie{ - {Name: "abc", Value: "123"}, - {Name: "UserName", Value: "leolee"}, - }, - }, - } - step, err := har.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 TestMakeRequestCookiesWithProfile(t *testing.T) { - har := NewHAR("") - har.SetProfile(profilePath) - entry := &Entry{ - Request: Request{ - Method: "POST", - Cookies: []Cookie{ - {Name: "abc", Value: "123"}, - {Name: "UserName", Value: "leolee"}, - }, - }, - } - step, err := har.prepareTestStep(entry) - if !assert.NoError(t, err) { - t.Fatal() - } - - if !assert.Equal(t, map[string]string{ - "UserName": "debugtalk", - }, step.Request.Cookies) { - t.Fatal() - } -} - -func TestMakeRequestDataParams(t *testing.T) { - har := NewHAR("") - 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 := har.prepareTestStep(entry) - if !assert.NoError(t, err) { - t.Fatal() - } - - if !assert.Equal(t, "a=1&b=2", step.Request.Body) { - t.Fatal() - } -} - -func TestMakeRequestDataJSON(t *testing.T) { - har := NewHAR("") - entry := &Entry{ - Request: Request{ - Method: "POST", - PostData: PostData{ - MimeType: "application/json; charset=utf-8", - Text: "{\"a\":\"1\",\"b\":\"2\"}", - }, - }, - } - step, err := har.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) { - har := NewHAR("") - entry := &Entry{ - Request: Request{ - Method: "POST", - PostData: PostData{ - MimeType: "application/json; charset=utf-8", - Text: "", - }, - }, - } - step, err := har.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) { - har := NewHAR("") - 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: Value:map[BlnResult:true]] - Text: "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=", - Encoding: "base64", - }, - }, - } - step, err := har.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() - } -} diff --git a/hrp/internal/convert/har2case/har.go b/hrp/internal/convert/har2case/har.go deleted file mode 100644 index 6b98839a..00000000 --- a/hrp/internal/convert/har2case/har.go +++ /dev/null @@ -1,340 +0,0 @@ -package har2case - -import "time" - -/* -HTTP Archive (HAR) format -https://w3c.github.io/web-performance/specs/HAR/Overview.html -this file is copied from https://github.com/mrichman/hargo/blob/master/types.go -*/ - -// Har is a container type for deserialization -type Har struct { - Log Log `json:"log"` -} - -// Log represents the root of the exported data. This object MUST be present and its name MUST be "log". -type Log struct { - // The object contains the following name/value pairs: - - // Required. Version number of the format. - Version string `json:"version"` - // Required. An object of type creator that contains the name and version - // information of the log creator application. - Creator Creator `json:"creator"` - // Optional. An object of type browser that contains the name and version - // information of the user agent. - Browser Browser `json:"browser"` - // Optional. An array of objects of type page, each representing one exported - // (tracked) page. Leave out this field if the application does not support - // grouping by pages. - Pages []Page `json:"pages,omitempty"` - // Required. An array of objects of type entry, each representing one - // exported (tracked) HTTP request. - Entries []Entry `json:"entries"` - // Optional. A comment provided by the user or the application. Sorting - // entries by startedDateTime (starting from the oldest) is preferred way how - // to export data since it can make importing faster. However the reader - // application should always make sure the array is sorted (if required for - // the import). - Comment string `json:"comment"` -} - -// Creator contains information about the log creator application -type Creator struct { - // Required. The name of the application that created the log. - Name string `json:"name"` - // Required. The version number of the application that created the log. - Version string `json:"version"` - // Optional. A comment provided by the user or the application. - Comment string `json:"comment,omitempty"` -} - -// Browser that created the log -type Browser struct { - // Required. The name of the browser that created the log. - Name string `json:"name"` - // Required. The version number of the browser that created the log. - Version string `json:"version"` - // Optional. A comment provided by the user or the browser. - Comment string `json:"comment"` -} - -// Page object for every exported web page and one object for every HTTP request. -// In case when an HTTP trace tool isn't able to group requests by a page, -// the object is empty and individual requests doesn't have a parent page. -type Page struct { - /* There is one object for every exported web page and one - object for every HTTP request. In case when an HTTP trace tool isn't able to - group requests by a page, the 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 and 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 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 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 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"` -} diff --git a/hrp/testcase.go b/hrp/testcase.go index 41826716..66690340 100644 --- a/hrp/testcase.go +++ b/hrp/testcase.go @@ -193,17 +193,17 @@ func convertValidatorCompat2GoEngine(Validators []interface{}) (err error) { } validatorMap := iValidator.(map[string]interface{}) validator := Validator{} - _, checkExisted := validatorMap["check"] - _, assertExisted := validatorMap["assert"] - _, expectExisted := validatorMap["expect"] + 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 = validatorMap["check"].(string) - validator.Assert = validatorMap["assert"].(string) - validator.Expect = validatorMap["expect"] - if msg, existed := validatorMap["msg"]; existed { - validator.Message = msg.(string) + validator.Check = iCheck.(string) + validator.Assert = iAssert.(string) + validator.Expect = iExpect + if iMsg, msgExisted := validatorMap["msg"]; msgExisted { + validator.Message = iMsg.(string) } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator @@ -212,13 +212,16 @@ func convertValidatorCompat2GoEngine(Validators []interface{}) (err error) { if len(validatorMap) == 1 { // Python engine style for assertMethod, iValidatorContent := range validatorMap { - checkAndExpect := iValidatorContent.([]interface{}) - if len(checkAndExpect) != 2 { + validatorContent := iValidatorContent.([]interface{}) + if len(validatorContent) > 3 { return fmt.Errorf("unexpected validator format: %v", validatorMap) } - validator.Check = checkAndExpect[0].(string) + validator.Check = validatorContent[0].(string) validator.Assert = assertMethod - validator.Expect = checkAndExpect[1] + validator.Expect = validatorContent[1] + if len(validatorContent) == 3 { + validator.Message = validatorContent[2].(string) + } } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator @@ -294,23 +297,26 @@ func convertValidatorCompat2PyEngine(Validators []interface{}) (err error) { if len(validatorMap) == 1 { // Python engine style for _, iValidatorContent := range validatorMap { - checkAndExpect := iValidatorContent.([]interface{}) - if len(checkAndExpect) != 2 { + validatorContent := iValidatorContent.([]interface{}) + if len(validatorContent) > 3 { return fmt.Errorf("unexpected validator format: %v", validatorMap) } } continue } - _, checkExisted := validatorMap["check"] - _, assertExisted := validatorMap["assert"] - _, expectExisted := validatorMap["expect"] + iCheck, checkExisted := validatorMap["check"] + iAssert, assertExisted := validatorMap["assert"] + iExpect, expectExisted := validatorMap["expect"] if checkExisted && assertExisted && expectExisted { // Golang engine style - var iValidatorContent []interface{} - iValidatorContent = append(iValidatorContent, validatorMap["check"]) - iValidatorContent = append(iValidatorContent, validatorMap["expect"]) + var validatorContent []interface{} + validatorContent = append(validatorContent, iCheck) + validatorContent = append(validatorContent, iExpect) + if iMsg, msgExisted := validatorMap["msg"]; msgExisted { + validatorContent = append(validatorContent, iMsg) + } newValidatorMap := make(map[string]interface{}) - newValidatorMap[validatorMap["assert"].(string)] = iValidatorContent + newValidatorMap[iAssert.(string)] = validatorContent Validators[i] = newValidatorMap continue } From fc59a74b506226dcf593d3dd9c174bf8a22aeeff Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Wed, 25 May 2022 09:45:49 +0800 Subject: [PATCH 05/14] bugfix: postman empty body options --- hrp/cmd/har2case.go | 1 - hrp/internal/convert/converter_postman.go | 15 ++++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go index 9d5f2f10..7d6f1994 100644 --- a/hrp/cmd/har2case.go +++ b/hrp/cmd/har2case.go @@ -29,7 +29,6 @@ var har2caseCmd = &cobra.Command{ } if flagCount > 1 { return errors.New("please specify at most one conversion flag") - } convert.Run(har2caseOutputType, har2caseOutputDir, har2caseProfilePath, args) return nil diff --git a/hrp/internal/convert/converter_postman.go b/hrp/internal/convert/converter_postman.go index d373c23a..bb746e87 100644 --- a/hrp/internal/convert/converter_postman.go +++ b/hrp/internal/convert/converter_postman.go @@ -427,14 +427,19 @@ func (s *stepFromPostman) makeRequestBody(item *TItem, steps []*hrp.TStep) error 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) + err = fmt.Errorf("make request body (raw) failed: %v", p) } }() - // extract language type + // extract language type, default languageType: text + languageType := "text" iOptions := item.Request.Body.Options - iLanguage := iOptions.(map[string]interface{})["raw"] - languageType := iLanguage.(map[string]interface{})["language"].(string) + 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 @@ -442,7 +447,7 @@ func (s *stepFromPostman) makeRequestBodyRaw(item *TItem) (err error) { var iBody interface{} err = json.Unmarshal([]byte(rawBody), &iBody) if err != nil { - return errors.Wrap(err, "make request body raw failed") + return errors.Wrap(err, "make request body (raw -> json) failed") } s.Request.Body = iBody } else { From 5f2c443e95f8f1affb31906c9bcbf932bf717ae2 Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Wed, 25 May 2022 15:44:21 +0800 Subject: [PATCH 06/14] change: hrp convert README.md --- hrp/internal/convert/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/hrp/internal/convert/README.md b/hrp/internal/convert/README.md index d31381be..da426350 100644 --- a/hrp/internal/convert/README.md +++ b/hrp/internal/convert/README.md @@ -23,13 +23,13 @@ Global Flags: -l, --log-level string set log level (default "INFO") ``` -`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 等格式的外部脚本转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化,输出的测试用例文件名格式为 `不带扩展名的原文件名称` + `_test` + `json/yaml/go/py` 后缀。 +`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 文件或 curl/Apache ab 指令转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化。 该指令所有选项的详细说明如下: -1. `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入的外部脚本转化为对应形态的测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例 +1. `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入转化为对应形态的测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例 2. `--output-dir` 后接测试用例的期望输出目录的路径,用于将转换生成的测试用例输出到对应的文件夹 -3. `--profile` 后接 profile 配置文件的路径,目前支持替换(不存在则会创建)或者覆盖输入的外部脚本/测试用例中的 `Headers` 和 `Cookies` 信息,profile 文件的后缀可以为 `json/yaml/yml`,下面给出两类 profile 配置文件的示例: +3. `--profile` 后接 profile 配置文件的路径,目前支持替换(不存在则会创建)或者覆盖输入的外部脚本/测试用例中的 `Headers` 和 `Cookies` 信息,profile 文件的后缀可以为 `json/yaml/yml`,下面给出两类 profile 配置文件的示例: - 根据 profile 替换指定的 `Headers` 和 `Cookies` 信息 ```yaml headers: @@ -48,9 +48,10 @@ cookies: ## 注意事项 -1. `hrp convert` 可以自动识别输入类型,因此不需要通过选项来手动制定输入类型,如遇到无法识别、不支持或转换失败的情况,则会输出错误日志并跳过,不会影响其他转换过程的正常进行 -2. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式 -3. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格 +1. 输出的测试用例文件名格式为 `Postman 工程文件名称(不带拓展名)` + `_test` + `.json/.yaml/.go/.py 后缀`,如果该文件已经存在则会进行覆盖 +2. `hrp convert` 可以自动识别输入类型,因此不需要通过选项来手动制定输入类型,如遇到无法识别、不支持或转换失败的情况,则会输出错误日志并跳过,不会影响其他转换过程的正常进行 +3. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式 +4. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格 ## 转换流程图 From dd1cd5938f278de67505e4fdf4b27c02f351b086 Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Thu, 26 May 2022 16:04:46 +0800 Subject: [PATCH 07/14] update: httprunner make v4 --- hrp/internal/convert/converter.go | 5 +- hrp/internal/convert/converter_har.go | 32 +-------- hrp/internal/convert/converter_json.go | 27 +------- hrp/internal/convert/converter_postman.go | 35 +--------- hrp/internal/convert/converter_yaml.go | 27 +------- hrp/step_api.go | 2 +- hrp/testcase.go | 83 ++--------------------- httprunner/compat.py | 32 ++++++--- httprunner/compat_test.py | 55 +++++++++++++-- httprunner/make.py | 16 ++--- 10 files changed, 95 insertions(+), 219 deletions(-) diff --git a/hrp/internal/convert/converter.go b/hrp/internal/convert/converter.go index 8733614c..63e07690 100644 --- a/hrp/internal/convert/converter.go +++ b/hrp/internal/convert/converter.go @@ -241,7 +241,6 @@ func (c *TCaseConverter) ToGoTest() (string, error) { type ICaseConverter interface { Struct() *TCaseConverter ToJSON() (string, error) - ToJSONTemp() (string, error) ToYAML() (string, error) ToGoTest() (string, error) ToPyTest() (string, error) @@ -345,8 +344,8 @@ func makeTestCaseFromJSONYAML(iCaseConverter ICaseConverter) (*hrp.TCase, error) } func convertToPyTest(iCaseConverter ICaseConverter) (string, error) { - // convert to temporary json testcase compatible with python engine style - jsonPath, err := iCaseConverter.ToJSONTemp() + // convert to temporary json testcase + jsonPath, err := iCaseConverter.ToJSON() inputType := iCaseConverter.Struct().InputType if err != nil { return "", errors.Wrapf(err, "(%s -> pytest step 1) failed to convert to temporary json testcase", inputType.String()) diff --git a/hrp/internal/convert/converter_har.go b/hrp/internal/convert/converter_har.go index 1ee513ae..d35a9031 100644 --- a/hrp/internal/convert/converter_har.go +++ b/hrp/internal/convert/converter_har.go @@ -384,19 +384,6 @@ func (c *ConverterHAR) ToJSON() (string, error) { return jsonPath, nil } -func (c *ConverterHAR) ToJSONTemp() (string, error) { - tCase, err := c.makeTestCaseTemp() - if err != nil { - return "", err - } - jsonPath := c.converter.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(tCase, jsonPath) - if err != nil { - return "", err - } - return jsonPath, nil -} - func (c *ConverterHAR) ToYAML() (string, error) { tCase, err := c.makeTestCase() if err != nil { @@ -429,24 +416,7 @@ func (c *ConverterHAR) makeTestCase() (*hrp.TCase, error) { Config: c.prepareConfig(), TestSteps: teststeps, } - err = tCase.MakeCompat2GoEngine() - if err != nil { - return nil, err - } - return tCase, nil -} - -func (c *ConverterHAR) makeTestCaseTemp() (*hrp.TCase, error) { - teststeps, err := c.prepareTestSteps() - if err != nil { - return nil, err - } - - tCase := &hrp.TCase{ - Config: c.prepareConfig(), - TestSteps: teststeps, - } - err = tCase.MakeCompat2PyEngine() + err = tCase.MakeCompat() if err != nil { return nil, err } diff --git a/hrp/internal/convert/converter_json.go b/hrp/internal/convert/converter_json.go index 5aa0b69a..fc380142 100644 --- a/hrp/internal/convert/converter_json.go +++ b/hrp/internal/convert/converter_json.go @@ -37,19 +37,6 @@ func (c *ConverterJSON) ToJSON() (string, error) { return jsonPath, nil } -func (c *ConverterJSON) ToJSONTemp() (string, error) { - testCase, err := c.makeTestCaseTemp() - if err != nil { - return "", err - } - jsonPath := c.converter.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(testCase, jsonPath) - if err != nil { - return "", err - } - return jsonPath, nil -} - func (c *ConverterJSON) ToYAML() (string, error) { testCase, err := c.makeTestCase() if err != nil { @@ -91,19 +78,7 @@ func (c *ConverterJSON) makeTestCase() (*hrp.TCase, error) { if err != nil { return nil, err } - err = tCase.MakeCompat2GoEngine() - if err != nil { - return nil, err - } - return tCase, nil -} - -func (c *ConverterJSON) makeTestCaseTemp() (*hrp.TCase, error) { - tCase, err := makeTestCaseFromJSONYAML(c) - if err != nil { - return nil, err - } - err = tCase.MakeCompat2PyEngine() + err = tCase.MakeCompat() if err != nil { return nil, err } diff --git a/hrp/internal/convert/converter_postman.go b/hrp/internal/convert/converter_postman.go index bb746e87..bfa9a19e 100644 --- a/hrp/internal/convert/converter_postman.go +++ b/hrp/internal/convert/converter_postman.go @@ -144,19 +144,6 @@ func (c *ConverterPostman) ToJSON() (string, error) { return jsonPath, nil } -func (c *ConverterPostman) ToJSONTemp() (string, error) { - testCase, err := c.makeTestCaseTemp() - if err != nil { - return "", err - } - jsonPath := c.converter.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(testCase, jsonPath) - if err != nil { - return "", err - } - return jsonPath, nil -} - func (c *ConverterPostman) ToYAML() (string, error) { testCase, err := c.makeTestCase() if err != nil { @@ -192,27 +179,7 @@ func (c *ConverterPostman) makeTestCase() (*hrp.TCase, error) { Config: c.prepareConfig(casePostman), TestSteps: teststeps, } - err = tCase.MakeCompat2GoEngine() - if err != nil { - return nil, err - } - return tCase, nil -} - -func (c *ConverterPostman) makeTestCaseTemp() (*hrp.TCase, error) { - casePostman, err := c.load() - if err != nil { - return nil, err - } - teststeps, err := c.prepareTestSteps(casePostman) - if err != nil { - return nil, err - } - tCase := &hrp.TCase{ - Config: c.prepareConfig(casePostman), - TestSteps: teststeps, - } - err = tCase.MakeCompat2PyEngine() + err = tCase.MakeCompat() if err != nil { return nil, err } diff --git a/hrp/internal/convert/converter_yaml.go b/hrp/internal/convert/converter_yaml.go index 81d1b1a9..2ad783b1 100644 --- a/hrp/internal/convert/converter_yaml.go +++ b/hrp/internal/convert/converter_yaml.go @@ -34,19 +34,6 @@ func (c *ConverterYAML) ToJSON() (string, error) { return jsonPath, nil } -func (c *ConverterYAML) ToJSONTemp() (string, error) { - testCase, err := c.makeTestCaseTemp() - if err != nil { - return "", err - } - jsonPath := c.converter.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(testCase, jsonPath) - if err != nil { - return "", err - } - return jsonPath, nil -} - func (c *ConverterYAML) ToYAML() (string, error) { testCase, err := c.makeTestCase() if err != nil { @@ -74,19 +61,7 @@ func (c *ConverterYAML) makeTestCase() (*hrp.TCase, error) { if err != nil { return nil, err } - err = tCase.MakeCompat2GoEngine() - if err != nil { - return nil, err - } - return tCase, nil -} - -func (c *ConverterYAML) makeTestCaseTemp() (*hrp.TCase, error) { - tCase, err := makeTestCaseFromJSONYAML(c) - if err != nil { - return nil, err - } - err = tCase.MakeCompat2PyEngine() + err = tCase.MakeCompat() if err != nil { return nil, err } diff --git a/hrp/step_api.go b/hrp/step_api.go index 7b57d896..1c9992ba 100644 --- a/hrp/step_api.go +++ b/hrp/step_api.go @@ -47,7 +47,7 @@ func (path *APIPath) ToAPI() (*API, error) { if err != nil { return nil, err } - err = convertValidatorCompat2GoEngine(api.Validators) + err = convertCompatValidator(api.Validators) convertExtract(api.Extract) return api, err } diff --git a/hrp/testcase.go b/hrp/testcase.go index 6548ab55..4182cec7 100644 --- a/hrp/testcase.go +++ b/hrp/testcase.go @@ -64,7 +64,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { return nil, errors.New("incorrect testcase file format, expected config in file") } - err = tc.MakeCompat2GoEngine() + err = tc.MakeCompat() if err != nil { return nil, err } @@ -173,11 +173,11 @@ type TCase struct { TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` } -// MakeCompat2GoEngine converts TCase compatible with Golang engine style -func (tc *TCase) MakeCompat2GoEngine() (err error) { +// MakeCompat converts TCase compatible with Golang engine style +func (tc *TCase) MakeCompat() (err error) { defer func() { if p := recover(); p != nil { - err = fmt.Errorf("[MakeCompat2GoEngine] convert compat testcase error: %v", p) + err = fmt.Errorf("[MakeCompat] convert compat testcase error: %v", p) } }() for _, step := range tc.TestSteps { @@ -194,7 +194,7 @@ func (tc *TCase) MakeCompat2GoEngine() (err error) { } // 2. deal with validators compatibility - err = convertValidatorCompat2GoEngine(step.Validators) + err = convertCompatValidator(step.Validators) if err != nil { return err } @@ -205,7 +205,7 @@ func (tc *TCase) MakeCompat2GoEngine() (err error) { return nil } -func convertValidatorCompat2GoEngine(Validators []interface{}) (err error) { +func convertCompatValidator(Validators []interface{}) (err error) { for i, iValidator := range Validators { if _, ok := iValidator.(Validator); ok { continue @@ -272,77 +272,6 @@ func convertCheckExpr(checkExpr string) string { return strings.Join(checkItems, ".") } -// MakeCompat2PyEngine converts TCase compatible with Python engine style -func (tc *TCase) MakeCompat2PyEngine() (err error) { - defer func() { - if p := recover(); p != nil { - err = fmt.Errorf("[MakeCompat2PyEngine] convert compat testcase error: %v", p) - } - }() - for _, step := range tc.TestSteps { - // 1. deal with request body compatibility - if step.Request != nil && step.Request.Body != nil { - if strings.HasPrefix(step.Request.Headers["Content-Type"], "application/json") { - step.Request.Json = step.Request.Body - step.Request.Body = nil - continue - } - step.Request.Data = step.Request.Body - step.Request.Body = nil - } - - // 2. deal with validators compatibility - err = convertValidatorCompat2PyEngine(step.Validators) - if err != nil { - return err - } - } - return -} - -func convertValidatorCompat2PyEngine(Validators []interface{}) (err error) { - for i, iValidator := range Validators { - if v, ok := iValidator.(Validator); ok { - var iValidatorContent []interface{} - iValidatorContent = append(iValidatorContent, v.Check) - iValidatorContent = append(iValidatorContent, v.Expect) - newValidatorMap := make(map[string]interface{}) - newValidatorMap[v.Assert] = iValidatorContent - Validators[i] = newValidatorMap - continue - } - validatorMap := iValidator.(map[string]interface{}) - // validator check priority: Python > Golang engine style - if len(validatorMap) == 1 { - // Python engine style - for _, iValidatorContent := range validatorMap { - validatorContent := iValidatorContent.([]interface{}) - if len(validatorContent) > 3 { - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } - } - continue - } - iCheck, checkExisted := validatorMap["check"] - iAssert, assertExisted := validatorMap["assert"] - iExpect, expectExisted := validatorMap["expect"] - if checkExisted && assertExisted && expectExisted { - // Golang engine style - var validatorContent []interface{} - validatorContent = append(validatorContent, iCheck) - validatorContent = append(validatorContent, iExpect) - if iMsg, msgExisted := validatorMap["msg"]; msgExisted { - validatorContent = append(validatorContent, iMsg) - } - newValidatorMap := make(map[string]interface{}) - newValidatorMap[iAssert.(string)] = validatorContent - Validators[i] = newValidatorMap - continue - } - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } - return -} func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) { testCases := make([]*TestCase, 0) diff --git a/httprunner/compat.py b/httprunner/compat.py index c7352f6a..4732783a 100644 --- a/httprunner/compat.py +++ b/httprunner/compat.py @@ -1,5 +1,5 @@ """ -This module handles compatibility issues between testcase format v2 and v3. +This module handles compatibility issues between testcase format v2, v3 and v4. """ import os import sys @@ -14,9 +14,8 @@ from httprunner.utils import sort_dict_by_custom_order def convert_variables( - raw_variables: Union[Dict, Text], test_path: Text + raw_variables: Union[Dict, Text], test_path: Text ) -> Dict[Text, Any]: - if isinstance(raw_variables, Dict): return raw_variables @@ -33,6 +32,18 @@ def convert_variables( ) +def _convert_request(request: Dict) -> Dict: + if "body" in request: + content_type = "" + if "headers" in request and "Content-Type" in request["headers"]: + content_type = request["headers"]["Content-Type"] + if content_type.startswith("application/json"): + request["json"] = request.pop("body") + else: + request["data"] = request.pop("body") + return _sort_request_by_custom_order(request) + + def _convert_jmespath(raw: Text) -> Text: if not isinstance(raw, Text): raise exceptions.TestCaseFormatError(f"Invalid jmespath extractor: {raw}") @@ -153,6 +164,9 @@ def _ensure_step_attachment(step: Dict) -> Dict: "name": step["name"], } + if "request" in step: + test_dict["request"] = _convert_request(step["request"]) + if "variables" in step: test_dict["variables"] = step["variables"] @@ -181,11 +195,11 @@ def _ensure_step_attachment(step: Dict) -> Dict: return test_dict -def ensure_testcase_v3_api(api_content: Dict) -> Dict: - logger.info("convert api in v2 to testcase format v3") +def ensure_testcase_v4_api(api_content: Dict) -> Dict: + logger.info("convert api in v2/v3 to testcase format v4") teststep = { - "request": _sort_request_by_custom_order(api_content["request"]), + "request": _convert_request(api_content["request"]), } teststep.update(_ensure_step_attachment(api_content)) @@ -202,8 +216,8 @@ def ensure_testcase_v3_api(api_content: Dict) -> Dict: } -def ensure_testcase_v3(test_content: Dict) -> Dict: - logger.info("ensure compatibility with testcase format v2") +def ensure_testcase_v4(test_content: Dict) -> Dict: + logger.info("ensure compatibility with testcase format v2/v3") v3_content = {"config": test_content["config"], "teststeps": []} @@ -221,7 +235,7 @@ def ensure_testcase_v3(test_content: Dict) -> Dict: teststep = {} if "request" in step: - teststep["request"] = _sort_request_by_custom_order(step.pop("request")) + pass elif "api" in step: teststep["testcase"] = step.pop("api") elif "testcase" in step: diff --git a/httprunner/compat_test.py b/httprunner/compat_test.py index 391133a1..a7877fb6 100644 --- a/httprunner/compat_test.py +++ b/httprunner/compat_test.py @@ -26,6 +26,53 @@ class TestCompat(unittest.TestCase): with self.assertRaises(exceptions.TestCaseFormatError): compat.convert_variables(None, "examples/data/a-b.c/1.yml") + def test_convert_request(self): + request_with_json_body = { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "k1": "v1", + "k2": "v2" + } + } + self.assertEqual( + compat._convert_request(request_with_json_body), + { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "application/json" + }, + "json": { + "k1": "v1", + "k2": "v2" + } + } + ) + + request_with_text_body = { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "text/plain" + }, + "body": "have a nice day" + } + self.assertEqual( + compat._convert_request(request_with_text_body), + { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "text/plain" + }, + "data": "have a nice day" + } + ) + def test_convert_jmespath(self): self.assertEqual(compat._convert_jmespath("content.abc"), "body.abc") self.assertEqual(compat._convert_jmespath("json.abc"), "body.abc") @@ -85,7 +132,7 @@ class TestCompat(unittest.TestCase): [{"eq": ["body[0].name", 201]}], ) - def test_ensure_testcase_v3_api(self): + def test_ensure_testcase_v4_api(self): api_content = { "name": "get with params", "request": { @@ -98,7 +145,7 @@ class TestCompat(unittest.TestCase): "validate": [{"eq": ["content.varB", 200]}, {"lt": ["json.0.varC", 0]}], } self.assertEqual( - compat.ensure_testcase_v3_api(api_content), + compat.ensure_testcase_v4_api(api_content), { "config": { "name": "get with params", @@ -126,7 +173,7 @@ class TestCompat(unittest.TestCase): }, ) - def test_ensure_testcase_v3(self): + def test_ensure_testcase_v4(self): testcase_content = { "config": {"name": "xxx", "base_url": "https://httpbin.org"}, "teststeps": [ @@ -150,7 +197,7 @@ class TestCompat(unittest.TestCase): ], } self.assertEqual( - compat.ensure_testcase_v3(testcase_content), + compat.ensure_testcase_v4(testcase_content), { "config": {"name": "xxx", "base_url": "https://httpbin.org"}, "teststeps": [ diff --git a/httprunner/make.py b/httprunner/make.py index 75a4e783..7fe473b3 100644 --- a/httprunner/make.py +++ b/httprunner/make.py @@ -11,8 +11,8 @@ from httprunner import __version__, exceptions from httprunner.compat import ( convert_variables, ensure_path_sep, - ensure_testcase_v3, - ensure_testcase_v3_api, + ensure_testcase_v4, + ensure_testcase_v4_api, ) from httprunner.loader import ( convert_relative_project_root_dir, @@ -332,8 +332,8 @@ def make_teststep_chain_style(teststep: Dict) -> Text: def make_testcase(testcase: Dict, dir_path: Text = None) -> Text: """convert valid testcase dict to pytest file path""" - # ensure compatibility with testcase format v2 - testcase = ensure_testcase_v3(testcase) + # ensure compatibility with testcase format v2/v3 + testcase = ensure_testcase_v4(testcase) # validate testcase format load_testcase(testcase) @@ -373,9 +373,9 @@ def make_testcase(testcase: Dict, dir_path: Text = None) -> Text: if not isinstance(test_content, Dict): raise exceptions.TestCaseFormatError(f"Invalid teststep: {teststep}") - # api in v2 format, convert to v3 testcase + # api in v2/v3 format, convert to v4 testcase if "request" in test_content and "name" in test_content: - test_content = ensure_testcase_v3_api(test_content) + test_content = ensure_testcase_v4_api(test_content) test_content.setdefault("config", {})["path"] = ref_testcase_path ref_testcase_python_abs_path = make_testcase(test_content) @@ -473,9 +473,9 @@ def __make(tests_path: Text): ) continue - # api in v2 format, convert to v3 testcase + # api in v2/v3 format, convert to v4 testcase if "request" in test_content and "name" in test_content: - test_content = ensure_testcase_v3_api(test_content) + test_content = ensure_testcase_v4_api(test_content) if "config" not in test_content: logger.warning( From 0e6250491e95ba6b5d9dc628f3f715c84768a18f Mon Sep 17 00:00:00 2001 From: xucong053 Date: Thu, 26 May 2022 18:25:49 +0800 Subject: [PATCH 08/14] fix: failed to generate a report in failfast mode #1315 --- hrp/runner.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hrp/runner.go b/hrp/runner.go index ca4c9db6..640e7af3 100644 --- a/hrp/runner.go +++ b/hrp/runner.go @@ -179,12 +179,13 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { }() for it := sessionRunner.parametersIterator; it.HasNext(); { - if err = sessionRunner.Start(it.Next()); err != nil { - log.Error().Err(err).Msg("[Run] run testcase failed") - return err - } + err = sessionRunner.Start(it.Next()) caseSummary := sessionRunner.GetSummary() s.appendCaseSummary(caseSummary) + if err != nil { + log.Error().Err(err).Msg("[Run] run testcase failed") + break + } } } s.Time.Duration = time.Since(s.Time.StartAt).Seconds() From d93a374fbf013714202e5ba3931fd473cdbb282c Mon Sep 17 00:00:00 2001 From: buyuxiang <347586493@qq.com> Date: Thu, 26 May 2022 18:33:37 +0800 Subject: [PATCH 09/14] fix: set ulimit error --- hrp/internal/boomer/ulimit.go | 1 + 1 file changed, 1 insertion(+) diff --git a/hrp/internal/boomer/ulimit.go b/hrp/internal/boomer/ulimit.go index b83585ea..504a534d 100644 --- a/hrp/internal/boomer/ulimit.go +++ b/hrp/internal/boomer/ulimit.go @@ -23,6 +23,7 @@ func SetUlimit(limit uint64) { } rLimit.Cur = limit + rLimit.Max = limit log.Info().Uint64("limit", rLimit.Cur).Msg("set current ulimit") err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) if err != nil { From 692704c4a42ae2e9d7333aaf5e99af48fa424b1b Mon Sep 17 00:00:00 2001 From: xucong053 Date: Mon, 23 May 2022 21:50:45 +0800 Subject: [PATCH 10/14] feat: create empty project by using hrp startproject #1280 --- examples/demo-with-go-plugin/proj.json | 6 -- .../testcases/ref_testcase.yml | 15 +++- .../testcases/requests.json | 80 +++++++++---------- .../testcases/requests.yml | 50 +++++++++--- examples/demo-with-py-plugin/proj.json | 6 -- .../testcases/ref_testcase.yml | 15 +++- .../testcases/requests.json | 80 +++++++++---------- .../testcases/requests.yml | 50 +++++++++--- examples/demo-without-plugin/proj.json | 6 -- hrp/cmd/scaffold.go | 4 +- hrp/internal/scaffold/examples_test.go | 24 +++++- hrp/internal/scaffold/main.go | 61 ++++++++------ .../testcases/demo_empty_request.json | 25 ++++++ .../testcases/demo_empty_request.yml | 16 ++++ .../templates/testcases/demo_ref_testcase.yml | 15 +++- .../templates/testcases/demo_requests.json | 80 +++++++++---------- .../templates/testcases/demo_requests.yml | 50 +++++++++--- 17 files changed, 377 insertions(+), 206 deletions(-) delete mode 100644 examples/demo-with-go-plugin/proj.json delete mode 100644 examples/demo-with-py-plugin/proj.json delete mode 100644 examples/demo-without-plugin/proj.json create mode 100644 hrp/internal/scaffold/templates/testcases/demo_empty_request.json create mode 100644 hrp/internal/scaffold/templates/testcases/demo_empty_request.yml diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json deleted file mode 100644 index 4dea84bc..00000000 --- a/examples/demo-with-go-plugin/proj.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "project_name": "demo-with-go-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-with-go-plugin", - "create_time": "2022-05-25T11:14:42.750876+08:00", - "hrp_version": "v4.1.0-beta" -} \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/ref_testcase.yml b/examples/demo-with-go-plugin/testcases/ref_testcase.yml index 6cf32323..b904706f 100644 --- a/examples/demo-with-go-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-go-plugin/testcases/ref_testcase.yml @@ -28,6 +28,15 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "foo1=$foo1&foo2=$foo3" validate: - - eq: ["status_code", 200] - - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - check: status_code + assert: equal + expect: 200 + msg: check status_code + - check: body.form.foo1 + assert: equal + expect: bar1 + msg: check body.form.foo1 + - check: body.form.foo2 + assert: equal + expect: bar21 + msg: check body.form.foo2 \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/requests.json b/examples/demo-with-go-plugin/testcases/requests.json index 9b4214d7..24d3d38d 100644 --- a/examples/demo-with-go-plugin/testcases/requests.json +++ b/examples/demo-with-go-plugin/testcases/requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -80,16 +80,16 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -109,28 +109,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/examples/demo-with-go-plugin/testcases/requests.yml b/examples/demo-with-go-plugin/testcases/requests.yml index bc9aa108..a713830b 100644 --- a/examples/demo-with-go-plugin/testcases/requests.yml +++ b/examples/demo-with-go-plugin/testcases/requests.yml @@ -27,10 +27,22 @@ teststeps: extract: foo3: "body.args.foo2" validate: - - eq: ["status_code", 200] - - eq: ["body.args.foo1", "debugtalk"] - - eq: ["body.args.sum_v", "3"] - - eq: ["body.args.foo2", "bar21"] + - 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: "3" + msg: check body.args.sum_v + - check: body.args.foo2 + assert: equal + expect: bar21 + msg: check body.args.foo2 - name: post raw text variables: @@ -44,8 +56,14 @@ teststeps: Content-Type: "text/plain" data: "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."] + - 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: @@ -58,7 +76,19 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "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"] + - 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 diff --git a/examples/demo-with-py-plugin/proj.json b/examples/demo-with-py-plugin/proj.json deleted file mode 100644 index cca69211..00000000 --- a/examples/demo-with-py-plugin/proj.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "project_name": "demo-with-py-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-with-py-plugin", - "create_time": "2022-05-25T11:14:52.333942+08:00", - "hrp_version": "v4.1.0-beta" -} \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/ref_testcase.yml b/examples/demo-with-py-plugin/testcases/ref_testcase.yml index 6cf32323..b904706f 100644 --- a/examples/demo-with-py-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-py-plugin/testcases/ref_testcase.yml @@ -28,6 +28,15 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "foo1=$foo1&foo2=$foo3" validate: - - eq: ["status_code", 200] - - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - check: status_code + assert: equal + expect: 200 + msg: check status_code + - check: body.form.foo1 + assert: equal + expect: bar1 + msg: check body.form.foo1 + - check: body.form.foo2 + assert: equal + expect: bar21 + msg: check body.form.foo2 \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/requests.json b/examples/demo-with-py-plugin/testcases/requests.json index 9b4214d7..24d3d38d 100644 --- a/examples/demo-with-py-plugin/testcases/requests.json +++ b/examples/demo-with-py-plugin/testcases/requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -80,16 +80,16 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -109,28 +109,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/examples/demo-with-py-plugin/testcases/requests.yml b/examples/demo-with-py-plugin/testcases/requests.yml index bc9aa108..a713830b 100644 --- a/examples/demo-with-py-plugin/testcases/requests.yml +++ b/examples/demo-with-py-plugin/testcases/requests.yml @@ -27,10 +27,22 @@ teststeps: extract: foo3: "body.args.foo2" validate: - - eq: ["status_code", 200] - - eq: ["body.args.foo1", "debugtalk"] - - eq: ["body.args.sum_v", "3"] - - eq: ["body.args.foo2", "bar21"] + - 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: "3" + msg: check body.args.sum_v + - check: body.args.foo2 + assert: equal + expect: bar21 + msg: check body.args.foo2 - name: post raw text variables: @@ -44,8 +56,14 @@ teststeps: Content-Type: "text/plain" data: "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."] + - 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: @@ -58,7 +76,19 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "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"] + - 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 diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json deleted file mode 100644 index 3fabec85..00000000 --- a/examples/demo-without-plugin/proj.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "project_name": "demo-without-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-without-plugin", - "create_time": "2022-05-25T11:14:53.862348+08:00", - "hrp_version": "v4.1.0-beta" -} \ No newline at end of file diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go index 93a8e4b8..42b8accb 100644 --- a/hrp/cmd/scaffold.go +++ b/hrp/cmd/scaffold.go @@ -32,7 +32,7 @@ var scaffoldCmd = &cobra.Command{ pluginType = scaffold.Py // default } - err := scaffold.CreateScaffold(args[0], pluginType, force) + err := scaffold.CreateScaffold(args[0], pluginType, empty, force) if err != nil { log.Error().Err(err).Msg("create scaffold project failed") os.Exit(1) @@ -43,6 +43,7 @@ var scaffoldCmd = &cobra.Command{ } var ( + empty bool ignorePlugin bool genPythonPlugin bool genGoPlugin bool @@ -55,4 +56,5 @@ func init() { 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") } diff --git a/hrp/internal/scaffold/examples_test.go b/hrp/internal/scaffold/examples_test.go index 3ec85cb5..dc992a99 100644 --- a/hrp/internal/scaffold/examples_test.go +++ b/hrp/internal/scaffold/examples_test.go @@ -6,19 +6,37 @@ import ( func TestGenDemoExamples(t *testing.T) { dir := "../../../examples/demo-with-go-plugin" - err := CreateScaffold(dir, Go, true) + err := CreateScaffold(dir, Go, false, true) if err != nil { t.Fatal() } dir = "../../../examples/demo-with-py-plugin" - err = CreateScaffold(dir, Py, true) + err = CreateScaffold(dir, Py, false, true) if err != nil { t.Fatal() } dir = "../../../examples/demo-without-plugin" - err = CreateScaffold(dir, Ignore, true) + err = CreateScaffold(dir, Ignore, false, true) + if err != nil { + t.Fatal() + } + + dir = "../../../examples/empty-demo-without-plugin" + err = CreateScaffold(dir, Ignore, true, true) + if err != nil { + t.Fatal() + } + + dir = "../../../examples/empty-demo-with-py-plugin" + err = CreateScaffold(dir, Py, true, true) + if err != nil { + t.Fatal() + } + + dir = "../../../examples/empty-demo-with-go-plugin" + err = CreateScaffold(dir, Go, true, true) if err != nil { t.Fatal() } diff --git a/hrp/internal/scaffold/main.go b/hrp/internal/scaffold/main.go index 3392d960..61ea346d 100644 --- a/hrp/internal/scaffold/main.go +++ b/hrp/internal/scaffold/main.go @@ -51,7 +51,7 @@ func CopyFile(templateFile, targetFile string) error { return nil } -func CreateScaffold(projectName string, pluginType PluginType, force bool) error { +func CreateScaffold(projectName string, pluginType PluginType, empty bool, force bool) error { // report event sdk.SendEvent(sdk.EventTracking{ Category: "Scaffold", @@ -127,38 +127,49 @@ func CreateScaffold(projectName string, pluginType PluginType, force bool) error return err } - // create demo testcases - if pluginType == Ignore { + // create project testcases + if empty { + // create empty project + err := CopyFile("templates/testcases/demo_empty_request.json", + filepath.Join(projectName, "testcases", "requests.json")) + if err != nil { + return err + } + } 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 } + } else { + // 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 + } + } + + if pluginType == Ignore { log.Info().Msg("skip creating function plugin") return nil } - - 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: diff --git a/hrp/internal/scaffold/templates/testcases/demo_empty_request.json b/hrp/internal/scaffold/templates/testcases/demo_empty_request.json new file mode 100644 index 00000000..fc76e4aa --- /dev/null +++ b/hrp/internal/scaffold/templates/testcases/demo_empty_request.json @@ -0,0 +1,25 @@ +{ + "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" + } + ] + } + ] +} \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml new file mode 100644 index 00000000..21586762 --- /dev/null +++ b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml @@ -0,0 +1,16 @@ +config: + name: "request methods testcase: empty testcase" + variables: + verify: False + +teststeps: + - name: + variables: + request: + method: GET + url: "https://" + validate: + - check: status_code + assert: equal + expect: 200 + msg: check status_code diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml index 6cf32323..b904706f 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml @@ -28,6 +28,15 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "foo1=$foo1&foo2=$foo3" validate: - - eq: ["status_code", 200] - - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - check: status_code + assert: equal + expect: 200 + msg: check status_code + - check: body.form.foo1 + assert: equal + expect: bar1 + msg: check body.form.foo1 + - check: body.form.foo2 + assert: equal + expect: bar21 + msg: check body.form.foo2 \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.json b/hrp/internal/scaffold/templates/testcases/demo_requests.json index 9b4214d7..24d3d38d 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.json +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -80,16 +80,16 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -109,28 +109,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.yml b/hrp/internal/scaffold/templates/testcases/demo_requests.yml index bc9aa108..a713830b 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.yml @@ -27,10 +27,22 @@ teststeps: extract: foo3: "body.args.foo2" validate: - - eq: ["status_code", 200] - - eq: ["body.args.foo1", "debugtalk"] - - eq: ["body.args.sum_v", "3"] - - eq: ["body.args.foo2", "bar21"] + - 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: "3" + msg: check body.args.sum_v + - check: body.args.foo2 + assert: equal + expect: bar21 + msg: check body.args.foo2 - name: post raw text variables: @@ -44,8 +56,14 @@ teststeps: Content-Type: "text/plain" data: "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."] + - 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: @@ -58,7 +76,19 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" data: "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"] + - 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 From cfa2cb96172cda7ab153244140912d3af3021122 Mon Sep 17 00:00:00 2001 From: xucong053 Date: Thu, 26 May 2022 17:39:11 +0800 Subject: [PATCH 11/14] fix: failed to locate root dir even if proj.json exist --- hrp/plugin.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hrp/plugin.go b/hrp/plugin.go index 67a86cfe..f9465a90 100644 --- a/hrp/plugin.go +++ b/hrp/plugin.go @@ -17,6 +17,7 @@ const ( goPluginFile = "debugtalk.so" // built from go plugin hashicorpGoPluginFile = "debugtalk.bin" // built from hashicorp go plugin hashicorpPyPluginFile = "debugtalk.py" // used for hashicorp python plugin + projectInfoFile = "proj.json" // used for ensuring root project ) func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, pluginDir string, err error) { @@ -118,9 +119,15 @@ func GetProjectRootDirPath(path string) (rootDir string, err error) { rootDir = filepath.Dir(pluginPath) return } + // fix: no debugtalk file in project but having proj.json created by startpeoject + projPath, err := locateFile(path, projectInfoFile) + if err == nil { + rootDir = filepath.Dir(projPath) + return + } // failed to locate project root dir - // maybe project plugin debugtalk.xx is not exist + // maybe project plugin debugtalk.xx and proj.json are not exist // use current dir instead return os.Getwd() } From ef73243e0431caa9b4091976a7551f84611992ca Mon Sep 17 00:00:00 2001 From: xucong053 Date: Thu, 26 May 2022 20:11:46 +0800 Subject: [PATCH 12/14] fix: unittest --- examples/demo-with-go-plugin/proj.json | 6 ++++ .../testcases/ref_testcase.yml | 2 +- .../testcases/requests.json | 4 +-- .../testcases/requests.yml | 4 +-- examples/demo-with-py-plugin/proj.json | 6 ++++ .../testcases/ref_testcase.yml | 2 +- .../testcases/requests.json | 4 +-- .../testcases/requests.yml | 4 +-- examples/demo-without-plugin/proj.json | 6 ++++ .../templates/testcases/demo_ref_api.json | 34 +++++++++---------- .../templates/testcases/demo_ref_testcase.yml | 2 +- .../templates/testcases/demo_requests.json | 4 +-- .../templates/testcases/demo_requests.yml | 4 +-- 13 files changed, 49 insertions(+), 33 deletions(-) create mode 100644 examples/demo-with-go-plugin/proj.json create mode 100644 examples/demo-with-py-plugin/proj.json create mode 100644 examples/demo-without-plugin/proj.json diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json new file mode 100644 index 00000000..c845ecfa --- /dev/null +++ b/examples/demo-with-go-plugin/proj.json @@ -0,0 +1,6 @@ +{ + "project_name": "demo-with-go-plugin", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-go-plugin", + "create_time": "2022-05-26T20:08:49.164545+08:00", + "hrp_version": "v4.1.0-beta" +} \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/ref_testcase.yml b/examples/demo-with-go-plugin/testcases/ref_testcase.yml index b904706f..e102b9ec 100644 --- a/examples/demo-with-go-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-go-plugin/testcases/ref_testcase.yml @@ -26,7 +26,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - check: status_code assert: equal diff --git a/examples/demo-with-go-plugin/testcases/requests.json b/examples/demo-with-go-plugin/testcases/requests.json index 24d3d38d..162632b4 100644 --- a/examples/demo-with-go-plugin/testcases/requests.json +++ b/examples/demo-with-go-plugin/testcases/requests.json @@ -76,7 +76,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "text/plain" }, - "data": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." }, "validate": [ { @@ -105,7 +105,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "application/x-www-form-urlencoded" }, - "data": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" }, "validate": [ { diff --git a/examples/demo-with-go-plugin/testcases/requests.yml b/examples/demo-with-go-plugin/testcases/requests.yml index a713830b..988c970f 100644 --- a/examples/demo-with-go-plugin/testcases/requests.yml +++ b/examples/demo-with-go-plugin/testcases/requests.yml @@ -54,7 +54,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - check: status_code assert: equal @@ -74,7 +74,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - check: status_code assert: equal diff --git a/examples/demo-with-py-plugin/proj.json b/examples/demo-with-py-plugin/proj.json new file mode 100644 index 00000000..e950c12c --- /dev/null +++ b/examples/demo-with-py-plugin/proj.json @@ -0,0 +1,6 @@ +{ + "project_name": "demo-with-py-plugin", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-py-plugin", + "create_time": "2022-05-26T20:08:56.909632+08:00", + "hrp_version": "v4.1.0-beta" +} \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/ref_testcase.yml b/examples/demo-with-py-plugin/testcases/ref_testcase.yml index b904706f..e102b9ec 100644 --- a/examples/demo-with-py-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-py-plugin/testcases/ref_testcase.yml @@ -26,7 +26,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - check: status_code assert: equal diff --git a/examples/demo-with-py-plugin/testcases/requests.json b/examples/demo-with-py-plugin/testcases/requests.json index 24d3d38d..162632b4 100644 --- a/examples/demo-with-py-plugin/testcases/requests.json +++ b/examples/demo-with-py-plugin/testcases/requests.json @@ -76,7 +76,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "text/plain" }, - "data": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." }, "validate": [ { @@ -105,7 +105,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "application/x-www-form-urlencoded" }, - "data": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" }, "validate": [ { diff --git a/examples/demo-with-py-plugin/testcases/requests.yml b/examples/demo-with-py-plugin/testcases/requests.yml index a713830b..988c970f 100644 --- a/examples/demo-with-py-plugin/testcases/requests.yml +++ b/examples/demo-with-py-plugin/testcases/requests.yml @@ -54,7 +54,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - check: status_code assert: equal @@ -74,7 +74,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - check: status_code assert: equal diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json new file mode 100644 index 00000000..80cc140d --- /dev/null +++ b/examples/demo-without-plugin/proj.json @@ -0,0 +1,6 @@ +{ + "project_name": "demo-without-plugin", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-without-plugin", + "create_time": "2022-05-26T20:08:57.501166+08:00", + "hrp_version": "v4.1.0-beta" +} \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_api.json b/hrp/internal/scaffold/templates/testcases/demo_ref_api.json index 8e69392f..7bc33c5e 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_api.json +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_api.json @@ -8,16 +8,14 @@ "app_version": "2.8.6" }, "base_url": "https://postman-echo.com", - "herader": [ - { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Host": "postman-echo.com", - "User-Agent": "PostmanRuntime/7.28.4" - } - ], + "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" @@ -48,16 +46,16 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.headers.postman-token", - "ea19464c-ddd4-4724-abe9-5e2b254c2723" - ] + "check": "body.headers.postman-token", + "assert": "equal", + "expect": "ea19464c-ddd4-4724-abe9-5e2b254c2723", + "msg": "check body.headers.postman-token" } ] }, diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml index b904706f..e102b9ec 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml @@ -26,7 +26,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - check: status_code assert: equal diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.json b/hrp/internal/scaffold/templates/testcases/demo_requests.json index 24d3d38d..162632b4 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.json +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.json @@ -76,7 +76,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "text/plain" }, - "data": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." }, "validate": [ { @@ -105,7 +105,7 @@ "User-Agent": "funplugin/${get_version()}", "Content-Type": "application/x-www-form-urlencoded" }, - "data": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" }, "validate": [ { diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.yml b/hrp/internal/scaffold/templates/testcases/demo_requests.yml index a713830b..988c970f 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.yml @@ -54,7 +54,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - check: status_code assert: equal @@ -74,7 +74,7 @@ teststeps: headers: User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - check: status_code assert: equal From 434decaa78c1b0b9d2cc4d7bc9d3236ed222dcd7 Mon Sep 17 00:00:00 2001 From: xucong053 Date: Thu, 26 May 2022 22:06:02 +0800 Subject: [PATCH 13/14] fix: modify logic of generating empty project --- docs/cmd/hrp.md | 2 +- docs/cmd/hrp_boom.md | 2 +- docs/cmd/hrp_convert.md | 2 +- docs/cmd/hrp_har2case.md | 4 +- docs/cmd/hrp_pytest.md | 2 +- docs/cmd/hrp_run.md | 2 +- docs/cmd/hrp_startproject.md | 3 +- docs/cmd/hrp_wiki.md | 2 +- examples/demo-with-go-plugin/proj.json | 2 +- examples/demo-with-py-plugin/proj.json | 2 +- examples/demo-without-plugin/proj.json | 2 +- hrp/cmd/scaffold.go | 6 ++- hrp/internal/scaffold/examples_test.go | 20 ++-------- hrp/internal/scaffold/main.go | 54 +++++++++++++------------- 14 files changed, 48 insertions(+), 57 deletions(-) diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index 74a6c8b2..a1b46ac0 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -37,4 +37,4 @@ Copyright 2017 debugtalk * [hrp startproject](hrp_startproject.md) - create a scaffold project * [hrp wiki](hrp_wiki.md) - visit https://httprunner.com -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index f6eb3a55..78dc8845 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -42,4 +42,4 @@ hrp boom [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index c3b1b5cc..9d0bf745 100644 --- a/docs/cmd/hrp_convert.md +++ b/docs/cmd/hrp_convert.md @@ -22,4 +22,4 @@ hrp convert $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index a37bbf1e..8ca7631e 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -16,7 +16,7 @@ hrp har2case $har_path... [flags] -h, --help help for har2case -d, --output-dir string specify output directory, default to the same dir with har file -p, --profile string specify profile path to override headers and cookies - -j, --to-json convert to JSON format (default true) + -j, --to-json convert to JSON format (default) -y, --to-yaml convert to YAML format ``` @@ -24,4 +24,4 @@ hrp har2case $har_path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md index 30682a20..ebc5a25d 100644 --- a/docs/cmd/hrp_pytest.md +++ b/docs/cmd/hrp_pytest.md @@ -16,4 +16,4 @@ hrp pytest $path ... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index 46725b8a..2dd34349 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -35,4 +35,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index eb0b6bb9..00ab67ab 100644 --- a/docs/cmd/hrp_startproject.md +++ b/docs/cmd/hrp_startproject.md @@ -9,6 +9,7 @@ hrp startproject $project_name [flags] ### Options ``` + --empty generate empty project -f, --force force to overwrite existing project --go generate hashicorp go plugin -h, --help help for startproject @@ -20,4 +21,4 @@ hrp startproject $project_name [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/docs/cmd/hrp_wiki.md b/docs/cmd/hrp_wiki.md index 1219555f..932f1133 100644 --- a/docs/cmd/hrp_wiki.md +++ b/docs/cmd/hrp_wiki.md @@ -16,4 +16,4 @@ hrp wiki [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 26-May-2022 diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json index c845ecfa..1c051514 100644 --- a/examples/demo-with-go-plugin/proj.json +++ b/examples/demo-with-go-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-go-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-go-plugin", - "create_time": "2022-05-26T20:08:49.164545+08:00", + "create_time": "2022-05-26T22:08:10.455301+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-with-py-plugin/proj.json b/examples/demo-with-py-plugin/proj.json index e950c12c..a1f7d451 100644 --- a/examples/demo-with-py-plugin/proj.json +++ b/examples/demo-with-py-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-py-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-py-plugin", - "create_time": "2022-05-26T20:08:56.909632+08:00", + "create_time": "2022-05-26T22:08:18.580462+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json index 80cc140d..92230cbb 100644 --- a/examples/demo-without-plugin/proj.json +++ b/examples/demo-without-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-without-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-without-plugin", - "create_time": "2022-05-26T20:08:57.501166+08:00", + "create_time": "2022-05-26T22:08:19.331271+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go index 42b8accb..c51281ed 100644 --- a/hrp/cmd/scaffold.go +++ b/hrp/cmd/scaffold.go @@ -24,7 +24,9 @@ var scaffoldCmd = &cobra.Command{ } var pluginType scaffold.PluginType - if ignorePlugin { + if empty { + pluginType = scaffold.Empty + } else if ignorePlugin { pluginType = scaffold.Ignore } else if genGoPlugin { pluginType = scaffold.Go @@ -32,7 +34,7 @@ var scaffoldCmd = &cobra.Command{ pluginType = scaffold.Py // default } - err := scaffold.CreateScaffold(args[0], pluginType, empty, force) + err := scaffold.CreateScaffold(args[0], pluginType, force) if err != nil { log.Error().Err(err).Msg("create scaffold project failed") os.Exit(1) diff --git a/hrp/internal/scaffold/examples_test.go b/hrp/internal/scaffold/examples_test.go index dc992a99..3bde77b0 100644 --- a/hrp/internal/scaffold/examples_test.go +++ b/hrp/internal/scaffold/examples_test.go @@ -6,37 +6,25 @@ import ( func TestGenDemoExamples(t *testing.T) { dir := "../../../examples/demo-with-go-plugin" - err := CreateScaffold(dir, Go, false, true) + err := CreateScaffold(dir, Go, true) if err != nil { t.Fatal() } dir = "../../../examples/demo-with-py-plugin" - err = CreateScaffold(dir, Py, false, true) + err = CreateScaffold(dir, Py, true) if err != nil { t.Fatal() } dir = "../../../examples/demo-without-plugin" - err = CreateScaffold(dir, Ignore, false, true) + err = CreateScaffold(dir, Ignore, true) if err != nil { t.Fatal() } dir = "../../../examples/empty-demo-without-plugin" - err = CreateScaffold(dir, Ignore, true, true) - if err != nil { - t.Fatal() - } - - dir = "../../../examples/empty-demo-with-py-plugin" - err = CreateScaffold(dir, Py, true, true) - if err != nil { - t.Fatal() - } - - dir = "../../../examples/empty-demo-with-go-plugin" - err = CreateScaffold(dir, Go, true, true) + err = CreateScaffold(dir, Empty, true) if err != nil { t.Fatal() } diff --git a/hrp/internal/scaffold/main.go b/hrp/internal/scaffold/main.go index 61ea346d..0941838d 100644 --- a/hrp/internal/scaffold/main.go +++ b/hrp/internal/scaffold/main.go @@ -20,6 +20,7 @@ import ( type PluginType string const ( + Empty PluginType = "empty" Ignore PluginType = "ignore" Py PluginType = "py" Go PluginType = "go" @@ -51,7 +52,7 @@ func CopyFile(templateFile, targetFile string) error { return nil } -func CreateScaffold(projectName string, pluginType PluginType, empty bool, force bool) error { +func CreateScaffold(projectName string, pluginType PluginType, force bool) error { // report event sdk.SendEvent(sdk.EventTracking{ Category: "Scaffold", @@ -128,13 +129,14 @@ func CreateScaffold(projectName string, pluginType PluginType, empty bool, force } // create project testcases - if empty { + 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", @@ -142,34 +144,32 @@ func CreateScaffold(projectName string, pluginType PluginType, empty bool, force if err != nil { return err } - } else { - // 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 - } - } - - if pluginType == Ignore { 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: From dc091f5505ef22288cf5d1bc6d81a42af5150dd9 Mon Sep 17 00:00:00 2001 From: xucong053 Date: Fri, 27 May 2022 11:29:41 +0800 Subject: [PATCH 14/14] update templates/testcases --- docs/cmd/hrp.md | 2 +- docs/cmd/hrp_boom.md | 2 +- docs/cmd/hrp_convert.md | 2 +- docs/cmd/hrp_har2case.md | 2 +- docs/cmd/hrp_pytest.md | 2 +- docs/cmd/hrp_run.md | 2 +- docs/cmd/hrp_startproject.md | 2 +- docs/cmd/hrp_wiki.md | 2 +- examples/demo-with-go-plugin/proj.json | 2 +- .../testcases/ref_testcase.yml | 15 ++---- .../testcases/requests.yml | 50 ++++--------------- examples/demo-with-py-plugin/proj.json | 2 +- .../testcases/ref_testcase.yml | 15 ++---- .../testcases/requests.yml | 50 ++++--------------- examples/demo-without-plugin/proj.json | 2 +- .../testcases/demo_empty_request.yml | 5 +- .../templates/testcases/demo_ref_testcase.yml | 15 ++---- .../templates/testcases/demo_requests.yml | 50 ++++--------------- 18 files changed, 51 insertions(+), 171 deletions(-) diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index a1b46ac0..c9499319 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -37,4 +37,4 @@ Copyright 2017 debugtalk * [hrp startproject](hrp_startproject.md) - create a scaffold project * [hrp wiki](hrp_wiki.md) - visit https://httprunner.com -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index 78dc8845..429a0ed3 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -42,4 +42,4 @@ hrp boom [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index 9d0bf745..3083456c 100644 --- a/docs/cmd/hrp_convert.md +++ b/docs/cmd/hrp_convert.md @@ -22,4 +22,4 @@ hrp convert $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index 8ca7631e..592c5281 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -24,4 +24,4 @@ hrp har2case $har_path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md index ebc5a25d..711c8bac 100644 --- a/docs/cmd/hrp_pytest.md +++ b/docs/cmd/hrp_pytest.md @@ -16,4 +16,4 @@ hrp pytest $path ... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index 2dd34349..63da347e 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -35,4 +35,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index 00ab67ab..e55c5429 100644 --- a/docs/cmd/hrp_startproject.md +++ b/docs/cmd/hrp_startproject.md @@ -21,4 +21,4 @@ hrp startproject $project_name [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_wiki.md b/docs/cmd/hrp_wiki.md index 932f1133..2eecbdd0 100644 --- a/docs/cmd/hrp_wiki.md +++ b/docs/cmd/hrp_wiki.md @@ -16,4 +16,4 @@ hrp wiki [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 26-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json index 1c051514..2b2fcb6b 100644 --- a/examples/demo-with-go-plugin/proj.json +++ b/examples/demo-with-go-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-go-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-go-plugin", - "create_time": "2022-05-26T22:08:10.455301+08:00", + "create_time": "2022-05-27T11:34:23.903959+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/ref_testcase.yml b/examples/demo-with-go-plugin/testcases/ref_testcase.yml index e102b9ec..0816481c 100644 --- a/examples/demo-with-go-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-go-plugin/testcases/ref_testcase.yml @@ -28,15 +28,6 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" body: "foo1=$foo1&foo2=$foo3" validate: - - check: status_code - assert: equal - expect: 200 - msg: check status_code - - check: body.form.foo1 - assert: equal - expect: bar1 - msg: check body.form.foo1 - - check: body.form.foo2 - assert: equal - expect: bar21 - msg: check body.form.foo2 \ No newline at end of file + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "bar1"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/requests.yml b/examples/demo-with-go-plugin/testcases/requests.yml index 988c970f..034dbefb 100644 --- a/examples/demo-with-go-plugin/testcases/requests.yml +++ b/examples/demo-with-go-plugin/testcases/requests.yml @@ -27,22 +27,10 @@ teststeps: 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: "3" - msg: check body.args.sum_v - - check: body.args.foo2 - assert: equal - expect: bar21 - msg: check body.args.foo2 + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "debugtalk"] + - eq: ["body.args.sum_v", "3"] + - eq: ["body.args.foo2", "bar21"] - name: post raw text variables: @@ -56,14 +44,8 @@ teststeps: 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 + - 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: @@ -76,19 +58,7 @@ teststeps: 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 + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "$expect_foo1"] + - eq: ["body.form.foo2", "bar23"] + - eq: ["body.form.foo3", "bar21"] diff --git a/examples/demo-with-py-plugin/proj.json b/examples/demo-with-py-plugin/proj.json index a1f7d451..555bccd7 100644 --- a/examples/demo-with-py-plugin/proj.json +++ b/examples/demo-with-py-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-py-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-py-plugin", - "create_time": "2022-05-26T22:08:18.580462+08:00", + "create_time": "2022-05-27T11:34:31.852589+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/ref_testcase.yml b/examples/demo-with-py-plugin/testcases/ref_testcase.yml index e102b9ec..0816481c 100644 --- a/examples/demo-with-py-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-py-plugin/testcases/ref_testcase.yml @@ -28,15 +28,6 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" body: "foo1=$foo1&foo2=$foo3" validate: - - check: status_code - assert: equal - expect: 200 - msg: check status_code - - check: body.form.foo1 - assert: equal - expect: bar1 - msg: check body.form.foo1 - - check: body.form.foo2 - assert: equal - expect: bar21 - msg: check body.form.foo2 \ No newline at end of file + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "bar1"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/requests.yml b/examples/demo-with-py-plugin/testcases/requests.yml index 988c970f..034dbefb 100644 --- a/examples/demo-with-py-plugin/testcases/requests.yml +++ b/examples/demo-with-py-plugin/testcases/requests.yml @@ -27,22 +27,10 @@ teststeps: 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: "3" - msg: check body.args.sum_v - - check: body.args.foo2 - assert: equal - expect: bar21 - msg: check body.args.foo2 + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "debugtalk"] + - eq: ["body.args.sum_v", "3"] + - eq: ["body.args.foo2", "bar21"] - name: post raw text variables: @@ -56,14 +44,8 @@ teststeps: 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 + - 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: @@ -76,19 +58,7 @@ teststeps: 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 + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "$expect_foo1"] + - eq: ["body.form.foo2", "bar23"] + - eq: ["body.form.foo3", "bar21"] diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json index 92230cbb..72c78cbf 100644 --- a/examples/demo-without-plugin/proj.json +++ b/examples/demo-without-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-without-plugin", "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-without-plugin", - "create_time": "2022-05-26T22:08:19.331271+08:00", + "create_time": "2022-05-27T11:34:32.548637+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml index 21586762..38e7c4a8 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml @@ -10,7 +10,4 @@ teststeps: method: GET url: "https://" validate: - - check: status_code - assert: equal - expect: 200 - msg: check status_code + - eq: ["status_code", 200] diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml index e102b9ec..0816481c 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml @@ -28,15 +28,6 @@ teststeps: Content-Type: "application/x-www-form-urlencoded" body: "foo1=$foo1&foo2=$foo3" validate: - - check: status_code - assert: equal - expect: 200 - msg: check status_code - - check: body.form.foo1 - assert: equal - expect: bar1 - msg: check body.form.foo1 - - check: body.form.foo2 - assert: equal - expect: bar21 - msg: check body.form.foo2 \ No newline at end of file + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "bar1"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.yml b/hrp/internal/scaffold/templates/testcases/demo_requests.yml index 988c970f..034dbefb 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.yml @@ -27,22 +27,10 @@ teststeps: 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: "3" - msg: check body.args.sum_v - - check: body.args.foo2 - assert: equal - expect: bar21 - msg: check body.args.foo2 + - eq: ["status_code", 200] + - eq: ["body.args.foo1", "debugtalk"] + - eq: ["body.args.sum_v", "3"] + - eq: ["body.args.foo2", "bar21"] - name: post raw text variables: @@ -56,14 +44,8 @@ teststeps: 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 + - 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: @@ -76,19 +58,7 @@ teststeps: 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 + - eq: ["status_code", 200] + - eq: ["body.form.foo1", "$expect_foo1"] + - eq: ["body.form.foo2", "bar23"] + - eq: ["body.form.foo3", "bar21"]