+
如果你期望加入 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 核心用户群,请填写[用户调研问卷][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 引擎的风格
+
+
+## 转换流程图
+
+
+
+## 开发进度
+
+| 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 @@
+
\ 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 YM+O1y_HjMD$(DlKr6xF#g(jX)j#xP zHw@rhl@uLr1ylop*KWY1jRFKTz!%z? z>AH9Itx&5=0|k@B$Cn*^xt~^f?53h51ClqQ4ZPeu-00F u?p&N!^^NzT0PeFa| z)rW-V@r|Vge8Box5>KYOB5MvXfvn#)oYtr^kw5`xo==VWz!{J(z2=KdF%l0aK`e8l z3<#!Ago|~r
) zeO}v8&z0daJ0{K0`E=f))`pnC5LzLku8G~c{0MLrp)bJH)K;%z?=G4)-7)&XUkbn&7J7)vMly%cT{j@lnG}{OOVoEj%$zGY2UbCW zF$?_qK<`x`r!n8O&DagunS&t=?#RhZyjVAnq@%W`rM|j|j!y@Xh$tu`{N1i@z4`jr zEKl`$d+y=$Mx>}V5*KJ>nuJ-8thlZMj4v4|2Tu6t5m-UCjpr3vy34 9I~!#mCN)wCd?i{y#1U}S@3$Lxzqq152_Qx*pY@l`!5Lj%Q&Wp?_T?DJ&0F*D zEUz(NFoyg5me}z`ZeEI)hw25XzYn}P8Tpc1nXOhOHVCG!2?z3?7_Nob8}0x`*su{4 z;(O~$>4$G2%K{Fj^Hg_OQ9M8+ID@lGgOOh!?6A0-20B6sby5PA^Ygc=@4v^|7Z0nC z0UqBj4q||u0rBdBGX$_8JxAEm^veCRi^w0`3KjMlWZi1*?i0aWPp4zB(kRzf9R%i* zdsrD|ZGuK)RnwqPl8Yh7#dLu1+V|LVq5n*;*1k+As6yxKQHc|$jK7<56pzI>F%S2Q zepWw6VMb(AY?EFxVAPu8|IL%;8O`uVlh5#}PCjU1bVB`Q*{g|`i-K_>67(OU(=HjZ z2s{SgU5HqCJwwh6V3$e*08+KheRY^7*Dc10TGE0MV9d?s5e#Q~dKu(Y)y443SN)R4 zfK>If$Y3W-k@Xweo_p%EX4EXjoRS*;U=!OUIQ-^^e4u^QDYE(dRGW+~0f%#; X{fDuE|RJ(1w5^-2jp6gr``vInG-UoKiA$E {Q$=E6lYG#%VF#==Ba{2H3zNPl7aB8X-+d>{#3RCpn*&?)n#qwA z;bKqtlVBN&eP8+0>olqWigTW{#U)+gRI?rx*7>UtWHJt7EJqU~O<+&HtG)wxyUW{o zo(4IsjNf$~RCNQg#Q0-UCA1~Om(}|ctE0-{n02tT@4*Jp>Q5xNGx16Hw-R-E7*pqM z-@g`W+OpD2!V2-@bshBqO7&-~2oFAx;t^K`jiJoZ(OoNc Ppr-X3BA`D zaykX8wIA2X{SNezIxQV$aib=S(;TbOrjbVFk+4|@fg`Xf>U|o|ls?a7$E8%DnT#-_ z5^C`EwfdwNsl_KCu{I^*z;$vnn6a=h2*?f_VNjl?0m!{+9~FpeQKc*M`9^hS!fPHn z?w8B($n=#+9!1%5J^t6L9bVT}&lXf!e*4(Jh!tK`NfK?NlUmBvI{M*D7lvyuu{M{B z2r$|IhRpGlLYdn)4X(=SyS{k4F@;56Qy<#ET&7F?YI~Qw*PL;8Usp?eMQTxzc|&fj z;XWH3q2y_1OXR-Zyw^067}*NtTQNQspAvGuAmHOER=^JgB==f zJIRMe+>hpO@J~7E*px1FxzjUjE)B7(b_np7>GsxW?T<83xgCj~qWX|TbLpH4yo!c5 zm&f!K-uy3S<@kw-mZ$;3kU|@Jgxrhbz-(ukGEd-7$!UGg*JeXQa3k*-p@?*;)UJX- zi_?)8)^ LcbNqIK z6Ob=Q6Nm$|_+Pd0&l&<4j37;|%v-yV(#~f!I>9BoJ>Q3<|CfaOJ}}Vnf>AL5=xpxl z-5{0j+^q+@RJC`7W((bZ6_&@_=ha~79%=kD-ljZj%%610m=7K^Ldj?Oq<575>#hHV z_`Jl<0Z(E4Y8}7qOc@fcH#l?25`A1^|5O=(D5RigZl9G}5g%E72pkH4B>)GyDR*Z_ zc}QA5^tbBZel^(1aiOxUmV}8o;A|?_p`qm0fHSE1Q7g5+DI`P>r?>Z>YvgA^{8Mf( z!G*buHaY6CL PPx~k0@hb)%4=812oAi?KkzX18zavZlit(%3`xh7dy;}MCf`Yei ze^Zu8@6R_Kv_3q>6Moed{|-WtV1WNC=Kn9W`A0~y_}8-hBO>|lD(Y5;dUg+o)S7!Q z7mU|4fC2z0OCsB#dUW+EX#-JH{u!Di;adM}x`xbT@$JgB4#Yv(*xTdHd{t#^oY| b@q?1pGn(X*siF8o)Y zKpZJC_h&o!aj%++bTxtdvEBzS*G3o}2LFpw@?5-K5=s|Favkzt{~a0o7g9XlU>}@Q zI=fVuM(zW?h@QOa#D6YW0pI31czQdFBvm@?pL{j&O*i6?MShUFcD{*{>v{udk{B*S z&?*HWAl+bkfDtYu_xvKFYS*k3NO0 o7YyuI0-Qx*wzWH(yj|els?3ME z*!Njy)_nVP`-Tu!deS_+4VZ6XhIp*i><|GokBKmZsNNw07+Wo)dBS o1U{dX1DajT9 zCmWaJc}6qgr6t4mZQ?`#{G!?(c2IZSosO;p2pMyh0i^t{A)iFf*dZZgVX}L3TEsVs zkoEii^k@E9X7^42IOSO#tGxTdZ@H2^@(qTCEJYU_W; ?R?8L?#02nIfZ$-+y@jscx z*m9kOz%nVeUk?~jx!zT$w$SJg(g(Z05!l?K#wl3oI(_@Q`>U}^H&EJ~2Vl*_z70SH zq9jmuy?~T%tpPJj4ZC2cD)+u@Z9B$f?}kfH3K#MqU_A|}mKV_8^?Rc4mR2-eU_afO z+XQj1&(EeHRs-WjcCn0jO*M(@yt6{2dVURn(Mr`BRR>eNwnto=u>HUcT3vgrP|8lS zn{qU7sObuTgzO9~WdNXtSo$z9OL8slkVKgA*y>gs59UgGwwyy6TRW6wuP1-;r}8O_ zG(pH5%1QU|XvthIgqT665>aAfApi^$Qj=!+gkwmok+vt Ka90V|ai7z!(o!Ktd_NuwsAl5NgFh5U>_VrYnWNS?$DssI(i`Z!QdAY$FxzXVc| z^@UKqB*pilq! k z85ENK meC}Kf%p1t6b*9zD$Nj{^ zN3H2B1hV5llY!`#$Oxc$p{yHqtY>{IsE4o5aS+Zr^=w^XuMhaWiSjF@2+HzFOY2%p zc;(iIwuZ{EX4UtY#*j?fKorQ8wgpC=g!mUyGjmZCHyoxQoFXYUAhN9p++y;p)vdJ` zT#I*DWf{mY0BBOki0_R|_S&1b-lW!myXHn=JpgciLYe^D-GiW9EPI3Ny8$S9$$z zQ2o)C xz{y$yJYyn?3=()?#9YNvdkF$g`Nsp3npsAOo0tL znXRrEfbxz!GS9a6r4SPMp)R{?qA{rncJtqu58}}kcTGu3Z|thD27vPiY@9_@@d@_O zM5@>`RF@0;g00he=-!@VZ~dcAYGwg6Dm0xmDY_9d(R`f;UU*q(0c?ZJpFca}-Sg;0 zk^{ig>XSwxdtr(V8(V&L6AdAg!V1iSr>Mvwo2WdtwoKp9i*^1d$@c+>`4#9S31-QZ ze6gFxyV!i@G&jGBCNK?Cpg)DiZDI>$wg%54dY9AgX92Wsl!S#4kNL=MTU`dw_xj^j zt_hSzpstY*DYTv=k7cnITA)s8Ht;JehN-#+;q1%K(}fuaeCCSRcY`bxPDh$XUIb$^ z;hEMPV83})hiOePZ;yTddCWm-%bJ{7-1)~=&CpvwUnc>k07Ev9n@jcdf(UIiV#E9o zJsx*vGAk%3d|s6yLGZHY Pb7Ot=ZJ<8$r=fBB2R1+4y^l7N z_Xo5&HKLM;d3~22Xyg~yQs-Z9 zIrc<|<3)LDa%rls2+vB2fQH`Ff6&OB82G*KlXmsf0h8{)9QEW44%M$Z zGPv*k%p~Ysx6eZOLSMYJT>$kkI9HYg{}VnZPJaHh4#dpAO*4T$$fUxD#Pp1tMMavl z6V^a;aNXN$@nFV(?Y}%L`1pkfmOX`MS2P7gazWb%bBb~sA^C<6IW?;|2xbrUThX;t zp5S6^ i{Ub%34$jT7y2S!p5(!_lfumG`Yc_ak&oA^KmW+>s{dG z)napZdy|~hiu$f5^7VC8<=e|U%NN{7Xt%k^K_qwg)*^jdmuAAnfK-Y228_)d`xN65 z+Z`xp>BsJ$68-3itE4plAZ*mjShAmrR DoX) zg$lyAj-IewQEc2PsfhhYGh#CU 5=bqa>@ajUO+aV*np(WJgOnAQ zu;n+M7nH`O`2s$t@Y>Wy1&hvLV$ST48;jeALJ*KtFH38axDSc#qHH;=483-)wwT`I zCVN1?-tyiz2Kg#Jv%j4M1SQ{B5^Z90gfX3P)6Ss bHq3~l(6VWPd_T7t=lvUaw))RFJbt)u@5$K?aRPRQ5sAyClP mcDkk~=ET zq1uPG_I#zyVQePBC_JdQ%Yxo8$?Fp+!h@SI2q1C_-^IP+0O7-AnRQ+-H~#`6z1?MQ z7=_agIo-+K-0yf{xRKxOFg)?@eNYq8ui23Y*ap8fLWv+z^G_i)zk`W*kUgFSwH3cZ z0azlqXNJ3W2d$XutcJw?9DMn?{Np>7z${ANbY92kdgB1H;SRldMtr#LhY6-ap>wZ9 zpLm#l8qX1a aW)2TsA#Yg^j3CLpYXfaKC2yX=gB7ZNKk9w;LNl+4i6#70d_sNP2aeS zJ$>i2ukTv%I%?C*4`@i_5jsMw7r>9ZfRLB9&?NgT+)ekgUpAPtEpt3m_b=LOjw8 za~Xr>6H2UxQo=juS;+f0vEj8MbN`+$I tt zr?uWFREdMlWLio6%t?pS2e3G3sJ)>x9qQIn!#@t1$5`n$9mL=u$V^Lu&iBT5yJ z;vZ6%$$db<5X7V-L>o0vI+L<4bEzaE=nd-{x?~dVI?IMWzxwMkDb5a_Q5^lV0dZS_ zaj1j)hkS8hL=JJ5GTtz3(5GNtkYX)6i8Cs4x$`3R?2u}hlE9~r<~_ZCIw62}YhgE? znM`sn5Ah!8`pg*O%lmodOe}Glt;^Zd=dd9T%z`h0fYhhZ;E%e5Xh7c+QHic`H;Jds zizx@=7!=IpP1@szWTWe2{fxXz{Y@qWltExZ-BZ0DT+-*@aG(oi?xOQL2zMV^?*bGV z(# p33ED2kW?& zLThgsihLf(SCsc?LWT7 OqJxp_7&R5(GD;xVnrAt?m!Y5<=f&q8Z=;3GxTfNzk zr%ZFUOxeKF1jF!Ga+_wDVVqa2^!tp7!!?}9bDx}Krm*pw#f_HZa*zU`;D+;@hs D9o0|1|>Y2Df=K*TDcJB&&vZKnbs47WnVez z;+S5VOMBe1=IbH1sZGCc%kptPTafV@#&O>w-2M0FIye~$Q(R1!?-->0$#2R{VQI8= z#u>oUJ3%~?aaVR?17;F63f;koe%j70G_vF$l-;Kv%(>Hg7Gu=hWM`Q%uM}v&*0U<+ zXKm=CzTS}CtZspu&v!$6u(~V6O{pC2@Jso#i}s_?uX2dQ2^ ixW zx gZ?aL}UebR|87+>_`o^DXbVx8X;Cx;_ zZ5vbNGqJRQ0)V;7U2n~L%&wR(yc||VCDc=mgr36V@=#`>&%JT1A&^B!Zz2A3)eiUG zdYn3cgV*CTM=DQdc)p2`MtyMN!?F0ajD2z%3w-`^-5b_9qc8UO<4ERm+d}#8i3hit zjOE%N9EOmxhr>)h(mx9~#eX7u$$enddr;5JG?aCIP2)1xvK6VlRj=De=f(ug=J*rI zMx#nMhObobs0ve`e4DALt@yBbujnoY#GH-f$+|&mAaiMeYTfi~LlAKeqN1txJGpa+ z{BS@Bw9av{Ypbtf@lq{Uq#+IUVZcZK^C(EjhRUSn{m?%Qm8hfuvy|UEETxjOSWwXP zt(wha{}#bV-Df))$%-`J;#vYH2P(VW1Uc}{Cu;$n991dd&O@bvmW@E=D_VNhcPUdZ zS44%k2~R4#<>#n~qiWxOTO1`#AXSI(dfj|PIfm=q5z{sylN#m+k<$_|z_K1+lDO|D zTO#}h8doMqg0_c3gYQ)CosY=Egz*@5;Em 2&c2E(|7Hd0{gUyw?J^be=(f2!*KcBAio)Qz_ea< zvunK>VQFGs{j12Qe|e^~nDsN$=W~nGxzu1<=?THR+$xys%*#(7Cz@?9DN)|QX@DIW z-lBKpjO=)fGOEy}_3hI`@+>2Vai^rrfky3mtWv8YmmDt`ulmu_*mU^I7QRcN4=@tf zRx6tbgWTLp1=e~u^(W|#no)l#h4Nxw-?+}|J_W0NuEn{!is`f2TfM2Z$jkov+Jq zDAhzZsR}Rsp*vxXd_;2lZK{4bvUZT*e6+6c$!A;Eo%(M*5}cTf9(#Q*)paD-jrtOk zU?Q_iq5XVAYkjBv?q3G2ACrs+>K}TCdqwrNwO8racjkoOfwy?-KdC;b%hK&$(18Z6 z3t+-T#jS5TjH#sU9Vay`KQ|P_cC(va{vm5g92BPwfFP~E`lY3M(K$`^BW#3=o>S$* zP_%zqaQ%;QTrDEQjz4^7si}7WCpGdeLIgxFZ?x`^`pofyd=TtT*oa5r(~Sm>i|XJ$ zhr`{2c;TN(!)nz>niS-kh$!o&8 |Ra%T->aKflW09ms@ZU`G&tWt2z#i$q5y; ztL*asoK$*zpXk?qTEQJSTi?Y3I@Tg;Y&{#CW{lm{a{P8s5VHIt{XBBZ06FY4ZO6g) z9$X;1`L$yvqA|As>yOV8HXc>8qL2@PD%|Ti>j$ze _G{zNM`KXEz?m}%fL4Jh$8Z{Ty9tCZi*KF=(n(Y?|CzR;_OkLhJ< 7;74iHt1wh`NT&VV@ztObdhOGpW3~)7? zThelf?7d?8-E`;9CLn7xon%%CugSRR-W8=*b W*QG;}8{>tRQik-s>C|9Y{#g@SVh|F|{wyni>%uKFLh<9D=dOy !l@?=%wdR58kq1Xsos2^BnNV5N=1Fsy)KOcz-=D0SYz?oag7`$wIyE zjX5>fqyU+DZ+1} XqQponJ-{glwrtWxpb4_xPxj(*6shfE`YsY5@ zNZ^?XcYe> <-|826}?mH>j&mu+HZq%3?53&&{48 zCERB-&E(}DbLZD}E(&lIHoeRM+4Edvf&4*q? ^$+z z{*7d(549qdkMlXt#~b^N{ved^b7xO!-Ts|txX4n%r{Z>4w&7nTZUBvBo>+P8t*~z5 zZT5Sf=9rEG(>Ef3dT!bvoph8jK>k|BLCk&pH)n3UJ*A^b#54Hc@cq{wdt3Q5+3>A) zzm=j9X X<*=y=4goyvKr;*p#pW!8b6=JUg?Y2YY!+=QUDF$9TP`Fr% zoN*0(BRvjyl^H+>rOo1ZBDNg1A_}#|#l1(Afg(JFFp7yl4(u5`oeyjg#0SM$FYXmF zqSgRSgp7j|!#!Ap2w^=lJVN!%yBHz(rw5846eqtKFniv^zq|VPvHwpGKu+%` &OrpT=?wc2&bcfAH`HM#QLbh4r0#fx799*e?0 z-q469uEoAS0gt`DIxbU_N4R!*{I4hlfL35&NC{4D4X5-EM8&&LUux7{*=FQ&Nx+5Z z4hG*)3gU-rVnjr8opSo!;bI?90+I+v%8bUQIRy}}6QEI8od1Az8yh><5WFsV+wi4$ z|CQAdPV0??i|tAR9gFV&tPH%Kd5yC*ec`gpY66dI-^F$_f$sEe&mRG=D+b<}R&WIU zkRcZ|x!5j3-HFa9uY%VZ{vJloRjP`M?IJYPJ3A(%VZbmVMlZGtG_axqA2HG}%>B9% z?Iw`)$R0xDld|8FCZY#4n~}KaS&`^gevHA7$1W)soQ?1TXK+@#)ZwT|y0B=fue))O z#vt~tbZwdx+3)%It0DCwWuo~ObwiRgWrCkCSb#6647bX9t{f)`FDn73voar}2x$P* zO2QT+F*-ab&$gU7E#nQ+^o4{4KNKzy!{Pgw{R8>ELWRW`>R26A;=9P-WMR3Fv!8#^ zRmXj^Pic^;m*#Xt4V;QhDKU@`t&o1-MKG{qQ?*AD=tygMG;puNYA#s6Da}u9OVZfw z1NAY&LiCT`a(qzvk);uDSHZX{49jU%O*DE4pO= z5JKwSPiE=OZ4`(GN+6$Q(`w>y1a0Gc&NyAj!>?x)lk9GC?3YAIDuEm5Vc gtseI*xkc?r%dtJ8a TtqBn%&Kr7e`*_lYPHC|W$* z4kdH7_Tf5ev%IsNbp%%N$p473@ZA;;a?fAgHTlF=?^fyhd!#>d7rb_h5}V;vj=ifb z=%5Ir-;p4>H0{u{y3TQl$Z;FSn}|78E5d*^np0hWR%LF|NAnG{ciyd&mT2IBoG)V> z;x}7ko}6QnoLU`(g}(86(T1x+#QR?9)OKriqOrCe?69rUD`J@f9_~u{*(8jIOY6je z8J49I-MTk$_u0K|8`pye9u#?Hbb>xw!&~fW9NY<~Em~(K=FO7Zfu(Evjh?xQhr?*E zbOOZ3B&!t*L}p$n5bAd~E7|95EeuMq_Q3yG&Ube{Q_ZzT2mdA0pYqoOg%b^}e&xzz z!tAPRrao5CblwbZ*|w!1(&&n&7B)F`S~ie74zW2d)TlHN?b_RboyH_av h7h}Q%2qQfmhT#Sd>i@xozpO;6=%5rTAGhdCKN&yvXfa-;LO+N$Ff% z{2aae){jhXHm7Xdb6l!pUrZ9(8>iVAUr+Gzvlc7!aEi#hWN?yT8gCo2Y?mHB+hp88 z(&hv|TP1b$WtT7*6+1a4hWSFy+ UkkHtF}yq;DM``$eQ;SWj6U>pvh}Zg3uB zP4M1}ZStF+Glko>m`zJx&N*4jw{JUR8j2?N#=MW}vdT;xOU1lRh$I0rNx{<(MhNVb z2=ZAX4vh2|`O}3iwGd*h;U@5S4t6G*v+1~W-z7Ukw_G$1uG7A0*UwLOY$uA}Vkh01 zC=SKSaV!fpB!v0wg+LOg)_0CW&pHaJdPXQd8=v(#rJU5j &Rd^k3sR|J>ZC z{?sS127gw*pyTdgoGv_@#(WR9#W-|31$}CzG~8rpCsNeD0ZQP3sh1%4VE>V$=hU39 zv8qa*IKu(w)$xF{j`5$@yUe`S?m=%(3URGP`V2S}FOKMTUxsz~KPa})zlrVOnO|Xg zP||q36)IaI4aLsMSJ)Rnpd7k9QdXj@t@(W61=}|Mzve)5ppA|=zGo{?WG^- J754A&N4Lx>AzN+LdK`RB|+YP{9MJ^@XJ)p7^o PG7cqkzed~+Mq+lmtr+-Q>fynfwjq&pvtlCYgcUQdb2zN9>U+L^Pp*aP=N(r?kv z{WZSi-KiaZ>?g}a7dubjv&wvzUM<~5gxbd?o<*xt?47IRZENW3IW8+jy9`MoE87qe zEPwJG`}FWTWTpX}P?bD(Z4r?Qyc9-B1B-FtTb_)3ULW%lp)*u_$!U$8mO^``mBFuT zU;`CrFB>^dGoyjn)+VAL$)YQXfngpbIZ*<<0e=R(CHi`G^o`U_SSNxjj&hr*ihR zuYj?^$KUE}9)!G##ddD_dmIwERXBaVU~l{WF_cIRJ71d5S$CdkP;CMo-~g7P`lFv6 z4|!+eO4sMy(_Pmty!Uv5)DqRK4r_^4T6viaRi+Iddc#}^i%dhCPJ}WYiMj%7)uuL| zib&VUUHE@);+0VIS{XiA2q?jNt;PLrgJ;%fN6+YJ4TlaLg8yKqHPluI$9Y6k#53|^ zjl$INhMb1l6+0MX@Gc_`y)n1*=2{{%YVlOE4qaDJ#q4J(v<3`V>G~S?$1Y#ip$=;v zhM~2uxNz?-VR!LT_A)P~p%W0;_`NwH3Tm;3$cF@-l*Ge{4pBN{$bTfW`|3LvO?8Iq z#}mBw7+MWT*%H3k_V>QAG6=Xs1hGyJSH&fAA3odMr?Mwmt{$PfXJa?)@MW%lob;DQ z_o$uL=UEKZoxyP^gG)zmx&MP^D|PabOn)?y@w4=LlI6Fox|YxKw9-2 s0tDFdhP4)=vu?QvS$gG3+}Zb zS#I4~-~_hKs(G6u{`+A3^a-(>AH7&_^f1v?XNcOQjSt%HGR@g^Y<_Pg8n#F5iATC@ z1}Wps$hh@Gj1oFs-P=8Syy39!ZEi8llb%g0DDu76#-qK%n%C7KxT?zk!JhxbqyUNR zK#Ek2>2Nw9w)4**e5$LbHDGjt)2q(b*v?j#@sr&ckTQt KJKd zc%!>BeYL3=qbCM9&IhxHO9>>+ili+S={34H;GuDh#LM1STKtkw1omQsl_2TOOxu{q zx+6IFp~W&5$0wSZA}!f-gT|I^v-*iM;lRsGNW-zj#61|0N>!*Qj{x6xNCwMyhgZfk zWq{9aAg`SL`Ek7B#*AaFLieH_bsa0Vb-InmcYBifcpQJoPCNVLm6}5Ran1$1@vhP! zQe&$Q|N04PK0n*sITOtlQpxmi;p06YE9#;Sl4XXsW%3r!^5(d_UmRHB7>1HUHeH|U zicoQy*EHo|Psvz7uwQpe&-(mQ?2hLlUf!h{NUZy7do5hI4fzqTpci|HhGY`bp;OU( zXuEqzld?PRR3Y16YS2;eHHF{t+%NFbX?I`KkYuFt*V0^-4Xl3WXNN& X1hGnQ%ed&=iH&Q@r`ddIC_Bg-E0Ia+zO+a7)B z&zPC7BVzLeLp+()hj{uGV)Jd=p3E`~eUZcysr)e_MtL^p*zAAgRWeELnK&yp(fk(c z{nL@t4dTn=BH_Ck@7}D+7W2{A=8AsL#2Xc(e9vws+fJJ3SVI1Gv-?Onk5sn@s-&{D z${B`akR}k-M-KVS$Oa!%SBeBEinbk<>Q0j6+qCz48^`*biI(}#_BYpdH9yDdtgdR) z(A^0iUpgs1nz{RgFEe}z90&~U_HmTe41;Sh&09n lOFEnynCW1f$i);$X5JL|EzsLn4tR{oe0cr_T6 zGXrSWy!t>gbh-k2_b76!B1SrGLZQdt#|2+Ys_X;HG8C+8E^6igCmQC}9|}xZ>iEOm z 3C0JHkVhd@Zw7Ek2U|*iitJSYgRR0 zW4PFReaVlRS3eIMQWJbauiSn0dCad6Z^n5%%~_kQ)adb-h!X=N6w~EwFD~c^>4b zspJJvj|Q?^B)qA3zu)YjW>UvAghM}X`YAg*r_nS~#Et>s%>Lz+7%8#v;7B^yNqH}q z76&BdzmS`^3npfvUYT*0-n?RJU#Bh^B6UFalYwe8i;(T}?Ml;)yS6EbHS(rca8^VO z$k{SxBHXmE&4!jvun{d+A9RPM2x1i4+1q|25T^0A`zG?(lS58mO8Cu}a9DFIW`tWM z58q0fN}hZ^%aFC@Q?_}gA$z*(+hr{qk4PG(6cok_2@kIOv8&V)5b*{Z-t?AqAxkj* z<|md^gMN&EJi#^|jxSN~6=LS ~5$eZID}*h3%it zu{qA`#H#VG3Slc5WWia=ND{YZj89u z$FX7Dp=*D`)%vW6{b?> ?>(eslwa2 z)mm%EwiC3vG&ftlwAYYLe;nT7*-+srT2F-YBLBTD{%T4Pv`SF0i$Lg _<@mm$ov&AOlf5#}K(#4wi z?t9L5F2um(XFeAYtK-FWue=3b{1$ldGO#fT&w~&~N_h(Gx~Z;mYFtN0Unq3lP<>be z;#omz;ww- ^CSL9=>C)oi z0cFFC0UEKbRNjp&;5kNNMv?ep<-Fr7=Yb7QMxf$aPEQA9Dt6av7B~v8k90)DBa#K3 z^z(~@z?!-+VkazHTfv UR*oIWA1vFcCN3%lR+puzl}T~5`A z=9cBbXlal}yF{p~()=kW`FSUW@VHt60+99T$zh!na$~7UJMEWY)n4PbCO1TcE5blq z%4bU6Y*&$1LcZkms0K#{W+Ll%=9UJYi2-2{82f*|7i`EjMCzkw( u%2F|XqxJPo_d#xl;$GKTde8*O`Sle8)(#qiI>I!+#eIRwwTNQ!P zh67MwLB9SSwj%0O&0DYS4PO}SWt-PwdX!4W _d87IgfWV zJ1FtKCCR$nZ9}83P_i8J-kiu0&?^f@kr1(GR!F^4$KIV3_gIn%%OibrmE7d$DtmKp zQB+J!3`D|9Q3mPJ8`!%Zy7jP1i;3VDnb*)WHMUJmj> hCsUhatSNXCHJdv(3v`d(vAG8c{7iW6LBRza^!)0|S66C7Ub9Dk-OlBK5 zBNJk1^Obs_cNl|jixmE6Hy-Hb;nx}1ZBXdmUQQPFI!eH?rMMm>B^AY#4QGkUxUp4n zL6+D3kIHhX$s6Gh1CLDFUth`TCDXaX3GRT*27Oi{68Bb3|3!ZukL=FcXj15Qe75^o zAo4Q6p4{($VM+BrH|u^pu;#XZMbY-wDIXoUu{LI_;>q=?ZRBseYg`4mDSwpB?!P}7 zb&v*8xwyk|BFR5ccW2Q`#R933n)SHYA%%3dM>f}dD59fiRh}Md&<3qvoMJKm`fGKf zaC@1jQOb<-rjfMGi>CkC20+P({lNIY^uxod@}3)J&y#4s*35>ABgSsOuqgKAKl=@w zLz~;9rjSGcj(ANk;06jx`v5%_7o@khw;xfT|F#pqNV{$jac9s1;I85V)ldUTH8v9u zwt+VL_M|VAjH~Z^ouiurNi>~o&)7iLLom_9j}dV?o(ZdGj>DzDBHf~t^&VKd?%%>A zh!8B{QSvUPnqO=uQ1fPsLDIU5-Qe8rOS*4)2L?b|Y+PPPPTK%^W87b1HB#EvxY MjNHRfJyA7^jTUu@_n%B>R z&LW2pcgKZ70qn{OkN?NpK*~7(_6Y|7HM4(0Tg)D~hrg6hnl6kCs zt;- Jjel*1ZW9GlL~!xKutm$Og_lf&`uss)ncu??6kFxnHD zMsjB!mfD?o!m`{5xpoKg6OHo~;-ge+6)*$kKQPY5T3g;ZJubFYC^95x1FU3y+(gA6 zVGPaRO7y5@#6vK1H0%v*ah!=xK}E6GaAUDfgUQxC5Ssxzi=wI@x_cK%%^ec34cWJL z>>`R6k&zcOV-)jFU2T(CCfjccF?&hsHBJc^~I{`lEEp2ujx;y zP-59FoC3CT>>u?Mp;>8`XWteEnT8{vG>=2Egdt$6+gv$SWgz!0RX2RfsIh)&-|OTy z`+UnR2s;z(1x@WdG%y@u``*2;jB4I_^5Et_5-ZFBB{Yv%$HT38GV&kKWjd~0gP(mC z$2VR;{h^>$l*#_xFyxs4{tvA@W)V_6EBx*(d8PlHK?><<)H7lebY3Z=2&}9x8agDb z4D)K5O Yd^R3;F>mIo)uN<0+;uhd$7NcW6`+Y z*D*?pfqBU)qA4_3m3ky2;EP qC9?QYfX3`$!%-9o9o9J)LGP#>D7 zKI->+#R?DaviKX^Po7<*S4U>A=XdULU()t{TymF<#;C5^8jE+K88C3QL2G?qo`vi_ zw43GE>1WJ^w1<3rhgdm8^P3@jCN0`qcSSNI#uiwAXc486c+8?n@U~9fUc67VY$j33 z>oFEbMzjNXt$YCxMaHD)kYxGcVI7CQ2OpgmvgHq-7NeXFMZ>lr`c}JyIUdTz#m8(g zuI})POQaMZqKLWCd2iLBfO#nLbL7J{vgBVA4VX>ahZI;W2cfUSE94LO;Xf|p6cbJ3 zlHloI!?P>xB)%kYRqb+ni%2LMrg$jXn(z(gfw7(SDUlI*l)X;ZTV~^R@5#JQ%v_+r z5QwP+O{31%t_fSyG_xJXK)&4ZU7P8~$fzNQ*(;&bKYnYw;RZk0BqIj`v~EBZY$xc2 zBQlrI wVl|HC9$8(8T^?0_8ku zsX{vc2MJs+ kKdPTa_ XT%OH!$A+d~Eci@(MU$=5h=Z8X~j z$K^lyKPZhL>ewm3cBWO;E`L-a_ mqv@@n1Mb)Ix#r7_UG^K`_8GBx5*Ds`#&A^gxA??Hij_on0aK9V|_) z0YF?oy$vqVqU`I=e&+d@Gpu6qr?g~wmApe)1d4chjmwj*GjOK+@;1!qqX>)}{bU+i z3sc$vVSfZmS(~TF=eBL989y%GpI(@*%cxOK#b~J29o~tprX$l_)qrweuBT`YpiQnE zk#ww}9a3ST;9-~%w`Z&1q=mnX_c-vr_0v(q*74^|K)GA&bRTIPy_8fBiRXQE`*7FA zmRDKZTysEMJ-)&JRap3&A%r;`j?wO4Z|(ESO0S_%L6ZE_Q2w)u5ArBzJy3tNaVY0f zG?M+Kb~VcWz(jH9bhP4XZIOkoOt~+sO;1?_HX5JZ2uVVzK4~+-N0mG@k1NBw8F&bq zQ&YCNJDYP33{kArl%}eyVee5)_1iHo9UEWfm7QTk*(>M3)8@N2ZE>mk%1J0+;7i2) z34C_1A VC-aFiZNF_uu#GHItdP3Yin+`E!uAD|u4Q-13Zq?zJWVBh4wU8cBf(|ic-e!cab z8~trwr0eEATtNdA+qMq4ot}_x`ztiEYVx_(Y(tMkm@gU%d}HwHOTUK) uvz^zpA!s2{4zg$P}{^=VE&ki5EmdfIRD>A`}dd7Xm3fECNY@v=sRb+mPV6Kvg> z>$Vd9EY)U23mRmrVjW?O*m>_MUT{pAhC--m?X(W&M(Yji_AZ<1*zpsw3ILRu(l9*f zC0Y+Kwo(q^z#v`lWD{%^Mc1>?)_ZKHiWRSI??)2X@s5;mP5fXA^O{d!hZMUEN3vDD z)Nt(e+fT245IM!$*nbDt=8A#f1oT6jpI)@;&pn=#U0c$G>b3DLmfKHB;6l^uV!$`L zG8 v>}ijar6FNZJ~?+nEkY&&>uL48iSR`{2)lX5!_M fwDo5eiRq~$FKufg|PHPhMRaZ3HL zV)!cZCOYm$Fby^;+#%(Q4qsfFCD7bOtwtYL4o3K|x0@1`nN+iP`?LIW>KnBo;Fx~O zDNh4_K|bZ2&ssy+fyL&KmV#Q6|2WDUK$3l-u6XQ|n!%)+)5b)ALfEK_#CA~tuwwY} zjv1T+a$J4jyioRORavN%76ZmdZgr}K%x_-?YNl1bxiU&J`X0 @Ia3XKeK{uq=H&o3JW;B+6 zT6U8cL}qR(W#~4!8;_}3vAsC~5=hlpM#N6qE`<8=)5m^S%OwOw@<;>ru2LUNW!{t; zNn|EO-o^lu-GOMX*IIfThB%F)c$ip?*Xt!CsM-V@1<|JOe?Abj`CdC6=ER3Ged`se z^~n4~SM=SNsA#^B2mmfE+LcY_VI9d)x_s -=ICQc!b3gu{gPL>rvS-<4lOI^qRO0Ap|~wdr?G z5SBKcY47oR00KjO4jVmYIKj>o58Fr36c~N&9cAi=svWgJjI&40Z}Hrwuu#gQ2_OFj z wp*}6zE+&$fyqwX~fO@Ob4Na z90#G%OeEhqEJD21(shH~>T?eH8UK)P+Qln`bQ?c#J-PCQj(usvAGH{E2J0UI+ud); zRV)-8lTe17-{H9l|Mo(EZ(sMk7KK&2zE(SCsM4k50Hb^Ha)P;q{PR1{TnWg|4TX9! z%HLMqZxO*@==x$3D0YnC#Mp~yY9nOj1cDg>9L-WdQEX&Id9AOa?f}FI`dM+hXOU zr<%R|s>!CAhNL;=7o#E3Z!dbO&Swv=OWuQ`lP>Fd9~#>YV>E>&;HB>x19zhhdUo`! z e4sHaICWDCdw&A7oo&rD2KIFg{QlR|w zu&%xl7vcQ%q?A1m;-3wqDGy#ku!<%GsFLhNXXe!$Lt{e}^Yj2iUm_GFn6eXcw}Bgk z98x~D{~|$(&U _klLxOH%C=Wt!E 9f>h3FqdK~wf!H=5^V<88XqtJ)kDBlNxc&^e(YtZO4)d{T-dg_ z5q7GkZ7)&e@6NceH1=ch2+64j(?t+wNM_9xhXAUk003eebS5jjW^l%o!qa|%LLUF2 zZ#N8m;{z$k$|is-7%vM&^Y!`}`vAU=0Y5a-8kFg5 %1{e+OY4*rBQk@FL3#J+amC+F<=N6SYIRIO*C_{c8YwuZh>2U^ L}N 8CZiI-?=AIu+eq<}OBf`=#$Q^OnXXD- zU8N5H +6EX7vKHMxQ2ACzV|FpfW zl0Uy#uvLz}7C6EVu3*N6VsCLT(9~Sg9{mtIP_NMA_8=Ut{RDY$5`NzaqOY}^3FDft zHOD&k*OMXQbrr>|Mjclw&=+z1I~X?Z$pt=@A(q}eYLbk;`ElDKQ>hnPw{>r%)IfY= zi|;#2aQi69oIf}towN_@6p*|OU2Q>7u-`^gyKzH8(G?NttolkwEqF7LPHOi(-<-nX zA*Nl`^<%X60W*cE8Fn8MAvm{@eE Zv+(FV<* z_;M~LH+vm#u@PlV?<~Q(wO!}lKKVPuP1O1JV)WdTeTZK0Me #Id^aVk>ea{TdwlzQ5cQsUAbisMQ0_Xl6u8W+>wNKXBSB6*d{!Flc1< z4@?DWD9Mp+g)~T1D(XA V-c5weT{xvAIEj}PU~#hjogHio zh>X$U%IB6Y?`se7*F(>eZ4kAXj=!AI`s|zP6ZSAGd0QqHYD_Z2c=^h6Fn$@dM9+kkr!N7^X9HAr6z2$cMAcQ*QD`OdtlA_I0L1n0sT6l};m zlwJ|A&)J{E3%e?Fhs4h4x;quLuE>^l*rFg{GQ|LtNcoWoE(kh`P0h*X`UxajyNq-p zh+FOntXBH*AC&sZww#{y0b-QrX@D=L!s08_Hn^}LnYCu@UO9_Yuyk0p*>mADxZ^h9 za!UprSnZ+YyljEuAQ*jpGl);3@icqtipj>vc3)UT7$^Sw6ao!ZS&$T(r45ItT#U&F zmnj6~ur`dZPI|w9HH#llyA=@9$zV+?(>-#&CAU$I`$4v0Z%roI#oEHjXb_Y?-s2T= z%dDHu+3uG@t4|v6S+=SkH*a`1GHMgB*7pjN+U#g&4Uug#qVn2NL_|Ti3M%-HMl$>J zmS|{JBC~3p<|pEl+31~SKKEX-4|Z*uc|$I 9_g83#lou{5u>^FNPJFg6HLhMsVOd72F`f8{Gi{ztb zhtW!=OkK>%(E{{STc^akn*@?psOovjh4W36yyaRhj957@_Zc7Im*1ITpE5||6QyL{ zvG1OMRd7d5+oSk~y+6O#NAt+tCrar~V{W+#g~iRm;$_30fd4&b6M9o}nC7btNJ=9g z9ZRsrlC=8>$5XX$hAXGyy>in=Rw#M*L_(E_We7F}BV*^G|MnYB-h`GNq1)wn`R*W{ z9**2C`Ej1n;l|UW6%Eb+Xu?3X(k?dSq<~qH(j&&^R_t{}SsRx3jMjT*gx_1*R^ArG zZ#ga@2XX41K9^x8dE`#@-X?vylmHc0+HI# {?OF($)es@3*Cc z?9lF~xqRr2Lo{SAb 6l>(ocD=Y#$<6i{%wng#!8*jLOQGgu}rbW(6tAPCe4w79YL|G3C))zi^;Ljr} z@JF`qWqo(w2s`fI6R?3c+_TjMW^jdMgHT^&F14a V2_kJke8;izIZG^TDtzc#$j~mHXWK;$E9#nNasrm-D zg+x^K`;S^}_xCyK4TKe`IUourkX*DTS7$^#`sa$Iqw736yo^G(`iJUbhd^>7m+6*G zD0*eb^w(93fJh6P$~tbV5sqpe(uFomYVKpoGQH {wPQ+ilP*tB_ zovK7Uc)7rwrtgbR_<|0=83jacDW;EUC(uOj4~EvuucPDz#X#;N%i`t|DA$)+;d#$r zw0t=(V1BDUE6>hx61bmkq+P*d23gC>KY8LgraZU9%nx91BCX$MD0#;fA?*m#c#>j> z?M-g8*4xB5>TGe)m6&p8 2Ke)QvhV ze-C=0_Xcq)tbTFBhh<4bkBkv~#>)gHGiHu$Q)b%sUn}J0D;x_`Ws{r65^_yN`YX6r ztDw3I!*#rp)sj7N|E?qYVxWV6|Jiy<&kPZ>y6|wvN?iGF;nZnQk#1Ac3%!R2{v3YY zDWv09y}{+UV2Yk9&+x?aVT(4~ld tasuuOxi1MasRPT1ZVr$vm$@Pd`ia(`Zk zzn1olTYre(WcuAJil^wPD{V)v l2*GMYC;wG$ 13N~^HT3gx_qU67fTb6R!kZ#k#y!~a`mJcFB`(6QMs%AiXR>$LR{3J z%@kI mV@h#1nG+yZsBS#WLlf`k3S)nI1D-=Mm`&}f`eB*|Mo4lkuTbI);Q z+3?+=7AoGe?U1u8drLgywnkK%{4|EoQo+zfr7G)w42s(haEz}hxEU=$eKxmm4U9hS z(d&WU&AmSwt2&DW&N7MuKrng%Um2!*zl;38%2DDkn+^PQdBbnLkss-Hn0IZBLo#J= zv2h^l>23+_xOTzySu*>ug9SIGHPB^EF`ZZyv%C1nHNl_mR7_vSyO)3P8@r_}FcZ7n zayn5$1GCJm9XFtpO(K&1LPz}A&o&S4;BvgC(qffIH&hLl<92aL^LoKYCq%^)8*6%T zPjTh-fIsT_Uul#|Na%a@zWN7?0goIr7-St^7|hk9K2`Y|TwhuDWEa$+MGa~Wsr-A; zgDjl$j+6k(5&64d9v%%|zpFQ{odxP)9U1dIZB$>)V IY0a>p6%cDm~z9Xy3YoQ~|z&nySKX^LvgD--9`DhfHX# zaqPak0-eg?A!U5K<@o7m*=kP@InvsH!6ZqPprjO~Cl31E)nIXp07g8Z;an)k^ZO;R z!4lkb|I|1n*igQOf&4}P&K$TXo?t%TUyC59BoI2KlpOLS3gEn|KC;x(H)L#K|M5u1V++T~0? HYJ$A}9G{+F2Ia@D$B-xd#s# ze7CM6k0-LzWagTr!qc>Z-?$}8B>|a}C)R9A&A=ecRiHOklbdWu>U(qf$G)>|!)K@b zn7Kdx0#poUi&b^7s0*-{7b``94~~J3>xk(nr+1Vy)0H4Y2_YKKN!GLp5^NpcJ@x(z zE#<}ZNT3fC!)TYUae&DKuFfKNzUNozh3wRF=DX|24NPemsB&vskmza cW(ugBb#cacz5Mpg}FiUhToYG%Ti6)`Elh3_d4 zzWhXt3A CtwuXO=<78#Jci<#nL% zD#N>@*v%XPDYK67=_u6O#kMnpch)&5>P<)IMa1#Hkj#PD54bJB_27V-e48sgaqKfl z$UtjG3`1&| {8# (~5XV6{qa5 z&SB+$ XL(JbJ~n6_rMRX+GG zi`~e7Ul{@#bZf-6Wl-XS|Mzb&pp;d2IIRq?iG m_ z|C>vIfyln5!|CVeHbj8mWNmDWRSpd6HXqUd;WZBMNtrakQ7cgY2(H586lA8ia3r|W zaeE0sTmF&BzhUWr^9wLM5+bk^kO65L*$@)eKCqcxRY|~mRILX8hxfdc@>~+YC6GLp zX=B>2C7*Yiu>Dv51KNkHYaGPrZoq*6(s!Ezc#Z#!E`j!mykP%)9~Ts!W>m$4m`e|q z62rO)HwiIXtbl_5E>BlVrRKWA)-AtebV`EzPA%-{C`>=tn=}?r;|dK7o$VV-U3Kde zqeThG|L^VWe)7@NtSru8fG#UaM)14$e{rOe6zKEOi`&uZJUZIu!BT|Uxjy)>N akwm1%Jn5rBOkqUOA%R9C`)0v$HgZuDIPgSL;1 )`{O+E zN!LvU5Xco^@kYHC|6k>ObyU?^`>%?CfRuC#f=WswJ+yQx(%miHA|l-#a_CSA=@O(R zkIJF@(A|B2yV057JI*`ryldTi*Zt?tnl)=V$oIRy&wlo^pZa{B@BeH-MGeNaZNX4F zO~DZ54OSXD`-lH!xz{XViz=#MigjtpHfN&kWZik-(jZDjs~XeR276^|(5au;*MEK` zapBWcS(b~l?^&3s)@5D1%`J?22^BsdXT3JUt@jlRbljFlGE}*GB##-08_aHBaajBh zm&U)5P|#ojOYLT>Gp5}#lPf_WvH#%G+v1y|`BSp1sk{SPRn{jK `$Q z{#mbJxZ{hvB%N9|%R@!uL|{lndw0k})f@pX1u2hh1G)`UL)ch`3$gNV|ILmR&>`D6 z7SdY=;!6Ca{P>@%@!##+iAoT6Vo}u?dZfZU*uSfja~)hDme>EAVRVmDOO=&J8#AbL z-J DbN*gO(B>& zRU6x`v;b(BmtSkwKW{g=({97ni{4S>-79$#ME>9IERmZwEX-O}Od(gA4BTRH=?-Gu zvqTS!`sdXp(AWZb11qP7e?I+=LK1cji{hWJBZlP)P(v${5dkt(<_{}Sd~E!Wc3VQa z+=6sm+Y%%m#08sY;eTT|jWi$X+4wX%Ra$&n_I6y3Ig&eS2UpyJRj+;F_D_}y26+m1 z!^loDZp6x)aXU!al|Kg3Vp^%()9bavjXmi)2YtB>ERciVR6X6HsyU&a@%4*9J41`W zWKQ>UNiv&?+2vSIp^mi}8P#s+1=<1HYFviy$=3?e^E<_6Dk#4=G=z+@(>zTDxAtQR zi@9Byyr5pC%YsIK5U8kW)*bjQ^ ;r*!|E5_Ar;V+xr@5}Nj*5l!N$um;e0f>=5EbR&w4l@b^>BL2Ut+Y6 zBL%nM*@Q)Gf8t+JBlWOd7+zHDOgA2LT{niCI|A=83lu+m3{muWS;~qHhcXyDQo#6} z5xEr3 @rO#x-UMB8V|_##nHRkKbglI(>^pG? ~Y!0EtK_Pz$5Z{_~SU0#;*8YhA zW`$r(yUOl6XCrsw)QY)dVIep(?bHiPPUf?gjud|roJc`5YB~_b@Nr{a)DQE&( W>6Ja_GqIfKTS-E=3phP!x1$UJiCNUZxbM&hhUc%%JUDv^lq5^TqV*Jj!LSm zL5>S?VHJq(5|xNDg}RHA8h+)<@y<5n@U1vT;>dxg&Us)dbtyi*7<-2bcrS9rEFBA} z2(l Iw8A0piwm3`l*Fd84{`s+g;G5J6diW1QmwzN2OTfav|Uch7316P7t zJb<$mh16Lfuja3ZbTj^ cSMm%rzvpnWwM+%xb0^|6f{!gfOV0)$ z3vxJUg<0vE+aE`1>c7j7|B{N3iwVi#qrPLV^6jUx7P^o+ta&dcHl4~Fd)OF{YxAvF zx-!|Y;5~TP*KRiIlGGJ@ZXGD19r+>1E}dmi0p#!ULh2a)=D7h|sh%`|{o(CT<+04i zIwLpl7bvgitsQm;89b2g$fegCH&9r0Pv +!J9> zeyGtE&%nNaKZ#2b3G4QRK1Aqz-+I8M#QPq1Exv1NX!>FdR{ObAk_*LQcRfX(O=mx- zF7h_B<4gxRRz>^ZEcI#w3nrM^*j-$6C*aq_;f+Y#E7&t`|CnvvsHrLFCK 9tK>Uz+Z>qV}-XsCrRdfszk0tuI=^j$7VJ$7~D }c!ue_e$W8frw z*(r|md(Cw2`Hn1gW48tDZ~|3CK6{i3O`)uyz8y0QB4IP6-QwfeeW$z_H|1Y$4e{P% z1Yc5ll}1a~I*}jpP!L$tc0Ax3E(@*Q?Z#cKD1Q`Wow)B03OR{{CES9wi!wM^bil>< zBS@1A0ooQ#t{ns6TVF0VU4`-UrWqplbb7c=s#IMZ4L7lYAsVxjM5P;t{7I;~&q+oy z`;Ze01{Y}tB|uOoC&}M?Kh=25poJ%j{k?h#chhN~-M!zd7{JiB{&?YoKfe(D<{gl2 zFS)vs6PUF+{#64ys9tgGojP0ZsejaZ1y^&i8 Z;;4QPN{^Ig?PxY#s+sVy4u(f&ouc{aKnEAbOfCZci_?X2_RLIvp ziKR*~?Q>RS0o2f#ET;g7{4d49!qAdbV+Gy~C#)h(zXg5w1}u680iT@_md-tvFaofT zCs&46o7`KDhy$Lm5`7@64D{qrjT={}^}*oDv(#_HI?&m8qV3KXZlf_;Z?R8ff53S= zo__@y>$->i`K>i>%b9__jlKxSzpVv68^=0RbH{uB^fUHFgIrh9Xo _T$Nbj{YH`iC{Y^*it!_S$_$b5y zn 8mxj8qKy}hdl<#1vM&=mcJ?oves{W z6Dp&mua1Mrs{hhtK9Ff;2Qk4CT(`fn&;Ejs#JY>lAik}DQW|e6-W`PIg^d9&N%st` z14muzH~NtFrb9kRbu)vT=$Br^gZ+Y|sGhz6UyAP81mwk5Ky34F(0hiJ!|Rx?fqxHv zL4uw1HnmbMH8d{hE0#aeqQLQ2IZz(2S%!!9X)NiGYBZ` Y%Xmhe4B*9Qk8JWasr)u8K<0ktj zLm|QylLK-dH3I*kIRRRP7_%;0$@`z?1ir&K-Nn4riv-Z&&;v#d8j{+blz-u)E&SS& zO#lWDU|@A=vYGZicSNjs-}~Es1wJl=A+LcdowMID`f40DRVRy!0Oy#~1Rrx;2#N+~ zDG(Ipr31cq2|_slU PS#OOjzUL0++Uk0y^0Yo?v|`5kTzp& dS)2hv 0kPK7{9(& vttWvzr6AYHL7hXE`!y3uFTfh)+b+M?-=~*F_($X z-naaTVx^ix#X45N*b%nz!YLdgk2|NYUr=s~<`yb8;%h(vHAP&>F2Dx->2zg1$2j1J zc#T$AmItnU{TRD4Tmngc?2zRS`dQr7F>tRtymDI^uP(8eHh45pC$HsrG;kTzM!dEq zJCu~Vm1@P(aLVoYVyoHoQY`Xz$Z aC8U~D( z8X%Pj`P(wZqZh5(-j(p8QNS$$gM5ls9G)o-xHp~q6K&2&Nb_^qQ2uNEC4r!%YAI{8 zS04E%3;eJ{^b@p}C^?}>Gxb*r19OX0OiIGw$`s4R7T4bJBO#N1suLbZ!^rD-dm_H< z<2<9I2mn9A0?zj1<;Xl&NO@lpn^A9eq>$y5*Pi^ri41b$r=}Z`YF_ivQoUdT8%`TF z=vxgw7EhzmRRotFhw*%$=|n~T+Ws@S1vA_S4)aSne`-`wwhlOr;NLQ2`LxeIl`9HM z2C011L>=D6Rfhen$l5gr$0-7y6ui0vuD6H6Bi7}USc_7^r_m?Vdgo*NlrK!$q+(VX z8se6Ld-v470d!+g^i7>n87e&9OS^)02h>ObfC+wx=)#idpV~~alF$$C6QEw#&LYTC znlWLOPpuAX)1`hrB|EXqvvk=Q)Ek>qXW1VD?K_7y;U?>X;tAkifSmAR{pq;l%VtiU zO@un-Ny0E%-(Yf__ft{+$3DS_u&tJG=_0K?Tl_42POGVj34T|1T0%PMtImZ2yAZm+ zuq0#)>JLwjuXfNXm&~B#aeG#Twq?^w3qJv629b|{X^eCh2&|q1Bqv1Z%JyTGTlyVP zuYYQzY)*_sOK)Wq_XZ A3%yRe>)(^&QVx zFzxd?r$UP<%j$o4?D*DTvMU7V00katq!N<@+^$gC9l^hdAaT$hg06h)KQ^@ff6Srq zztdohu3RuJ=&50>Y4h}1D=3sw|6oOU7WoNb@E0 >Xp{C| z0a$1@3VxT~@4E{ Hj1Ry4?2w-`IfTY_g$b;8viYc!@|;PEf~!!UvAp> zDlHg~G7S>#5BXa@2~>C7b|HT}CM~;9m9=0dvH!x%V|{Q}+0kMz-T8w|Lh{Al)uicp z6LKSa?Ic!%8}&o{4)bO(`U3UR62N`(6hespJFyb}J0(=#f`^+vd-XOn+$RMe-kiat z>=WhyW5j${ bSGM5Z)us(s~88A>LmD{xW6|%_3 zz5qTq#7?Kb#dFkt#A|23_kn^&+Oq|QMK7+`FvF(Z=E7<|d~Go+byJ1 Tnp2e7q0dJ_I)W_0*u#q%U!poL_Yce98NL_Q;^-=2H!o+Q_WXc1oNNK zUeN5a0_2eQLHrfM=aoQL?a3AS#0y%Vj^qOnTXN3@ic*xh6L%sZitWiu2LRj?@8sM- zDDM-Pki7^1R)Azd!)M-9u5}5(rStQYq`Y78Ar~l>fVA%)DST<=FQOE8MQj=z#5n8* z>w;|L)xbGjy;nWuVP!o&U1>F(vD}9|_T*~*_Fg-Y-pw`fwOjpFcXytt7x)A04|0HD z%+u$klK_Xt0g2xBV}?0IefGOW%I9RR OGyZXKa<;-tf zk*TVE>#ge)+KbP>Q&AA5sfqaMKF!36l8SDU3MY+^PmLb_sC!1DN67T>yK=+0fORBB zjiyCDvrZl7?pbSa!hixl^D&9KM)X;9Ds{!oXCcf0zExZpU4bGK1B~&{By#w5N~p)& zx*r)FS8Dk09S)-TNbdrhlER`>cN3Ye|6w-IRv1VBmtHB6;BuOW<^Zrr&?|L$2&zA? zXahGoz3{89brGXomskcMb|YPnmE7L4uZIpGFMtX#zWAjx-(??RCSV(A5vOU0C`a53 zxv=|yC-Epj^=YOM2yZi@-fZW#AY+j)tD0F3oPz%9H#2t_@(ifFf!a1hM_lSJzbh3? z?;NWPWQh3B^e7Tvu`s~Fb^?zrNUN$uL3(Ma*&j9aY7lo#N}#|i?Z5^U&~lEajeC3g zo k7m<#m^xIx NgqkMOYj+jbJ##sJF#* z?nnlB(ET~baQYC&4C-dVCwpjS;$H7m0OgSd_2+QzCn`@So>V-k8KKnr4Sp>U`XuM^ z*-IKSh(q2@GYthWzIlejqrpM4_0WF{5KAlqHNv{+G{c8J#<@E?_nF;!Rc#jAP-=3wQ|F0cmL4XVeyMBc{hS*U8iDUEYsi99+fgnp;C}i12>!K@^KlzN(Qx zlqo)yVHZZiS06#ak9a)#@^Xj7tlDOlq>=4TEmk}ClG^+|&lcnj`kH8p#xD}r1m};# z;8eWmwS*5SK<;Zm-`9|GvsPrMcjmFd7Ik;NlGK#=w=k1?)9^5t$2{2-xO>N@-Y>l( z%G ?qpM^2Qgf9_$ zy_{jKy4;mBAEJOIxYpu_ eLlVf+Ky3b+zp9!rpy z>#@)!L;ODRSwO;GrQ3RS3@Qqo+^n^|f{hC7#~67!z;C{GWGI#~q)fLr0pW@hD<;ni zIPJ}Z06dm!>Z0GQA_Si4tYQ2n)JkG8e@&aZNIDbCe(rukxR+Iv+w)PHDd#GR6LLLr zrjbXb?ZL#M8@#A%!zI|ocl06pwE*C$$cpWKWFFiGt$_pbk4hz}Nx##3|0YD&Osoij z-G`uoy8~A*vD>mn5##zDw*G>^+L>1v?8vkswzd)^0DM~7_hbg3{(^CYTT$Cf2og*Z z{mQ?UHYZc;-+Ke?S5q%Q(hjNge!YCXlO!52pS)xnc}12O|IM|3 v?%Kwd7kHo zE$U7X7O`+o6^TM6s4eq0EiN0INOZFo_42L49VKyzidz9~+kOq=EG0}#Z(C5ce`9`- zDwUUAzUVW& >J*qL{3Qq_T4*)8Nx& zvzQbuhVdyyL~ai9e6-;;A%R?Xlt4GQ Mlq=cl>@hc91RSI(hy4Bn>oMKh$n$=lD%z50yI&tfs& z_+gKP8HJ^1je98z%>nW|w$+>J9G{7GEIY?7VeZUw*FrO`SR=$dB7-yf<=N6YxzNdC zQ!{waCy8h1+p6NNwcwfKx=uTRkv$Hq>@I5 z* Jt-_Av;up4#Ue$*cvcW0B$dN@tlOs7uu zW?o;xfKc~s4u^y-3<0^M+Bb_Tdbh*)mMqR@XJ0$7Z(N61wHM4WZWA4cSw<;8`V2IO zBK(*?F;}bF*4KD4g5gxp294_UYv3fC-eVT!FSutt=orRHR$;{lj8-TyV|IBKPh{@q zn7+1nLFPv;p2zm>rqGK|SHx&l*p$S^d2Fl1) |cY-K!7RT9)XWtTxGIVs7z zP|smA9U6V?R+Q$NE#$RM>-xPbo+=GIEAd>CmYy+r_B~-Y?}Fe3rZEMinKV{~UesS2 zes^GK4 a9Yr-0LzGBCwhU{QbmV#L9Ine<3>(cDNB=qq2NZ~bBLyy 0l)!A3cQ@5_mm17MMp93eAAX216?@(vm?W;;Q) B-b*%{1|_}GYtUS{A(?rfe?q#W~yGOSGIN1km<38fr2n|RB! zRTFFVBXNXUR5v)2GDcf{VaJn<*GIc4GoFxot8yOp^7UFOE-J>w{a2ZoS