From ed5d5afe2b3fe0b078afab067f2f892860c4cabe Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 23 May 2025 03:03:40 +0200 Subject: [PATCH 1/8] chore(yaml): manifest files adjusted, fixed Task file --- Taskfile.yml | 2 +- examples/{simple => server}/server.yaml | 0 examples/simple-http-tests/http_test.yaml | 36 +++++++++++++++++++++ examples/simple/http_test.yaml | 38 ----------------------- examples/{simple => values}/values.yaml | 0 5 files changed, 37 insertions(+), 39 deletions(-) rename examples/{simple => server}/server.yaml (100%) create mode 100644 examples/simple-http-tests/http_test.yaml delete mode 100644 examples/simple/http_test.yaml rename examples/{simple => values}/values.yaml (100%) diff --git a/Taskfile.yml b/Taskfile.yml index 03d8c48..914ce21 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,7 +3,7 @@ version: '3' vars: BINARY_NAME: qube BUILD_DIR: C:/Users/admin/go/bin - MAIN: ./cmd + MAIN: ./cmd/qube VERSION: sh: git describe --tags --abbrev=0 2>/dev/null || echo "dev" diff --git a/examples/simple/server.yaml b/examples/server/server.yaml similarity index 100% rename from examples/simple/server.yaml rename to examples/server/server.yaml diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml new file mode 100644 index 0000000..3b3c418 --- /dev/null +++ b/examples/simple-http-tests/http_test.yaml @@ -0,0 +1,36 @@ +version: v1 +kind: HttpTest +metadata: + name: simple-test-example + namespace: simple-http-tests +spec: + target: http://localhost:8081 + cases: + - name: Get All Users Test + method: GET + endpoint: /users + assert: + - target: status + equals: 200 + + - name: Create New User With Body + method: POST + endpoint: /users + body: + name: user_name + email: user_email@example.com + assert: + - target: status + equals: 201 + + - name: Always Fail Endpoint Test + method: GET + endpoint: /fail + assert: + - target: status + equals: 500 + + - name: Slow Endpoint Response Test + method: GET + endpoint: /slow?delay=2s + timeout: 3s # Delay if request is 2s, if set timeout less it will fail diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml deleted file mode 100644 index a19b77d..0000000 --- a/examples/simple/http_test.yaml +++ /dev/null @@ -1,38 +0,0 @@ -version: v1 -kind: HttpTest -metadata: - name: simple-http-test -spec: - target: simple-server - cases: - - name: user-register - method: POST - endpoint: /register - headers: - type: some_data - Authorization: some_jwt_token - body: - username: "example_username" - email: "example_email" - password: "example_password" - expected: - code: 201 - message: "User successfully registered" - timeout: 1s - async: true - repeats: 20 - - - name: user-login - method: GET - endpoint: /login - body: - email: "example_email" - password: "example_password" - expected: - code: 200 - - name: user-fetch - method: GET - endpoint: /users/{id} - expected: - code: 201 - message: "User not found" diff --git a/examples/simple/values.yaml b/examples/values/values.yaml similarity index 100% rename from examples/simple/values.yaml rename to examples/values/values.yaml From 9e82133b00e2e7bb836b58392bb837a77bdbb8ea Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 23 May 2025 03:04:26 +0200 Subject: [PATCH 2/8] feat(runner): added run cmd for running tests, fixed some issues, working --- cmd/cli/root.go | 3 + cmd/cli/run/run.go | 209 ++++++++++++++++++ .../core/manifests/kinds/tests/api/http.go | 2 +- internal/core/manifests/kinds/tests/base.go | 8 +- internal/core/runner/assert/runner.go | 62 ++++-- internal/core/runner/cli/output.go | 52 ++++- .../core/runner/executor/base_registry.go | 9 +- .../core/runner/executor/executors/http.go | 50 ++++- internal/core/runner/executor/plan.go | 56 +++-- internal/core/runner/hooks/hooks.go | 8 +- internal/core/runner/pass/runner.go | 8 +- internal/core/runner/values/extractor.go | 8 +- ui/cli/console.go | 12 + ui/cli/logger.go | 97 +++++++- ui/cli/styles.go | 16 +- 15 files changed, 509 insertions(+), 91 deletions(-) create mode 100644 cmd/cli/run/run.go diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 9fa8d0f..fe015e9 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -6,6 +6,8 @@ import ( "os/signal" "syscall" + "github.com/apiqube/cli/cmd/cli/run" + "github.com/apiqube/cli/cmd/cli/apply" "github.com/apiqube/cli/cmd/cli/check" "github.com/apiqube/cli/cmd/cli/cleanup" @@ -40,6 +42,7 @@ var rootCmd = &cobra.Command{ func Execute() { rootCmd.AddCommand( versionCmd, + run.Cmd, apply.Cmd, check.Cmd, cleanup.Cmd, diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go new file mode 100644 index 0000000..5a2f532 --- /dev/null +++ b/cmd/cli/run/run.go @@ -0,0 +1,209 @@ +package run + +import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/context" + "github.com/apiqube/cli/internal/core/runner/executor" + "github.com/apiqube/cli/internal/core/runner/hooks" + runner "github.com/apiqube/cli/internal/core/runner/plan" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/ui/cli" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "run", + Short: "Run tests by plan or generate it and run", + SilenceErrors: true, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + opts, err := parseOptions(cmd) + if err != nil { + cli.Errorf("Failed to parse provided values: %v", err) + return + } + + cli.Info("Loading manifests...") + loadedManifests, err := loadManifests(opts) + if err != nil { + cli.Errorf("Failed to load manifests: %v", err) + return + } + + cli.Infof("Loaded %d manifests", len(loadedManifests)) + cli.Info("Generating plan...") + + manager := runner.NewPlanManagerBuilder(). + WithManifests(loadedManifests...).Build() + + planManifest, err := manager.Generate() + if err != nil { + cli.Errorf("Failed to generate plan: %v", err) + return + } + + cli.Successf("Plan successfully generated") + + ctxBuilder := context.NewCtxBuilder(). + WithContext(cmd.Context()). + WithManifests(loadedManifests...) + + registry := executor.NewDefaultExecutorRegistry() + hooksRunner := hooks.NewDefaultHooksRunner() + + planRunner := executor.NewDefaultPlanRunner(registry, hooksRunner) + + runCtx := ctxBuilder.Build() + + if err = planRunner.RunPlan(runCtx, planManifest); err != nil { + return + } + + cli.Successf("Plan successfully runned") + }, +} + +func init() { + Cmd.Flags().StringArrayP("names", "n", []string{}, "Names of manifests to generate (comma separated)") + Cmd.Flags().StringP("namespace", "s", "", "Namespace of manifests to generate") + Cmd.Flags().StringArrayP("ids", "i", []string{}, "IDs of manifests to generate (comma separated)") + Cmd.Flags().StringArrayP("hashes", "H", []string{}, "Hash prefixes for manifests (min 5 chars each)") + + Cmd.Flags().StringP("file", "f", ".", "Path to manifest directory (default: current)") + + Cmd.Flags().BoolP("output", "o", false, "Make output after generating") + Cmd.Flags().String("output-path", "", "Output path to save the plan (default: current directory)") + Cmd.Flags().String("output-format", "yaml", "Output format (yaml|json)") +} + +type options struct { + names []string + namespace string + ids []string + hashes []string + + file string + + output bool + outputPath string + outputFormat string + + flagsSet map[string]bool +} + +func parseOptions(cmd *cobra.Command) (*options, error) { + opts := &options{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("names") { + opts.names, _ = cmd.Flags().GetStringArray("names") + } + if markFlag("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + } + if markFlag("ids") { + opts.ids, _ = cmd.Flags().GetStringArray("ids") + } + if markFlag("hashes") { + opts.hashes, _ = cmd.Flags().GetStringArray("hashes") + } + + if markFlag("file") { + opts.file, _ = cmd.Flags().GetString("file") + } + + if markFlag("output") { + opts.output, _ = cmd.Flags().GetBool("output") + } + if markFlag("output-path") { + opts.outputPath, _ = cmd.Flags().GetString("output-path") + } + if markFlag("output-format") { + opts.outputFormat, _ = cmd.Flags().GetString("output-format") + } + + exclusiveFlags := []string{"names", "namespace", "ids", "hashes", "file"} + + var usedFlags []string + for _, flag := range exclusiveFlags { + if opts.flagsSet[flag] { + usedFlags = append(usedFlags, "--"+flag) + } + } + + if len(usedFlags) > 1 { + return nil, fmt.Errorf( + "conflicting filters: %s\n"+ + "these filters cannot be used together, please use only one", + strings.Join(usedFlags, " and "), + ) + } + + if err := validateOptions(opts); err != nil { + return nil, err + } + + return opts, nil +} + +func validateOptions(opts *options) error { + if !opts.flagsSet["names"] && + !opts.flagsSet["namespace"] && + !opts.flagsSet["ids"] && + !opts.flagsSet["hashes"] && + !opts.flagsSet["file"] { + return fmt.Errorf("at least one generate filter must be specified") + } + return nil +} + +func loadManifests(opts *options) ([]manifests.Manifest, error) { + switch { + case opts.flagsSet["ids"]: + return store.Load(store.LoadOptions{ + IDs: opts.ids, + }) + + case opts.flagsSet["file"]: + loadedMans, cachedMans, err := io.LoadManifests(opts.file) + if err == nil { + cli.Infof("Manifests from provided path %s loaded", opts.file) + } + + loadedMans = append(loadedMans, cachedMans...) + return loadedMans, err + + default: + query := store.NewQuery() + if opts.flagsSet["names"] { + for _, name := range opts.names { + query.WithExactName(name) + } + } + + if opts.flagsSet["hashes"] { + for _, hash := range opts.hashes { + query.WithHashPrefix(hash) + } + } + + if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + } + + return store.Search(query) + } +} diff --git a/internal/core/manifests/kinds/tests/api/http.go b/internal/core/manifests/kinds/tests/api/http.go index 9fa6095..1da1492 100644 --- a/internal/core/manifests/kinds/tests/api/http.go +++ b/internal/core/manifests/kinds/tests/api/http.go @@ -22,7 +22,7 @@ type Http struct { Spec struct { Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` - Cases []HttpCase `yaml:"cases" json:"cases" valid:"required,min=1,max=100,dive"` + Cases []HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` } `yaml:"spec" json:"spec" validate:"required"` kinds.Dependencies `yaml:",inline" json:",inline" validate:"omitempty"` diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index e55532f..4cc06b4 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -11,7 +11,7 @@ type HttpCase struct { Url string `yaml:"url,omitempty" json:"url,omitempty" validate:"omitempty,url"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,min=1,max=100"` Body map[string]any `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,min=1,max=100"` - Assert *Assert `yaml:"assert,omitempty" json:"assert,omitempty" validate:"omitempty"` + Assert []*Assert `yaml:"assert,omitempty" json:"assert,omitempty" validate:"omitempty,min=1,max=50,dive"` Save *Save `yaml:"save,omitempty" json:"save,omitempty" validate:"omitempty"` Pass []Pass `yaml:"pass,omitempty" json:"pass,omitempty" validate:"omitempty,min=1,max=25,dive"` Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"omitempty,duration"` @@ -19,7 +19,11 @@ type HttpCase struct { } type Assert struct { - Assertions []AssertElement `yaml:",inline,omitempty" json:",inline,omitempty" validate:"required,min=1,max=50,dive"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,min=3,max=128"` + Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` + Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` + Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` + Template string `yaml:"template,omitempty" json:"template,omitempty" validate:"omitempty,min=1,max=100,contains_template"` } type AssertElement struct { diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 7ae2021..d7cec82 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -3,35 +3,71 @@ package assert import ( "fmt" "net/http" - "reflect" "strings" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" - "github.com/tidwall/gjson" ) +type Type string + +const ( + Status Type = "status" + Body Type = "body" + Headers Type = "headers" +) + +func (t Type) String() string { + return string(t) +} + type Runner struct{} func NewRunner() *Runner { return &Runner{} } -func (a *Runner) Assert(_ interfaces.ExecutionContext, assert *tests.Assert, _ *http.Response, raw []byte, _ any) error { - for _, el := range assert.Assertions { - val := gjson.GetBytes(raw, el.Target).Value() +func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert, resp *http.Response, body []byte) error { + for _, assert := range asserts { + switch assert.Target { + case Status.String(): + return r.assertStatus(ctx, assert, resp) + case Body.String(): + return r.assertBody(ctx, assert, resp, body) + case Headers.String(): + return r.assertHeaders(ctx, assert, resp) + default: + return fmt.Errorf("assert failed: unknown assert target %s", assert.Target) + } + } + return nil +} - if el.Equals != nil && !reflect.DeepEqual(val, el.Equals) { - return fmt.Errorf("expected %v to equal %v", val, el.Equals) +func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { + if assert.Equals != nil { + expectedCode, ok := assert.Equals.(int) + if !ok { + return fmt.Errorf("assertion failed, expected status correct value got %v", assert.Equals) } - if el.Contains != "" { - if s, ok := val.(string); !ok || !strings.Contains(s, el.Contains) { - return fmt.Errorf("expected %v to contain %q", val, el.Contains) - } + + if resp.StatusCode != expectedCode { + return fmt.Errorf("assertion failed, expected status code %v, got %v", expectedCode, resp.StatusCode) } - if el.Exists && val == nil { - return fmt.Errorf("expected %v to exist", el.Target) + } + + if assert.Contains != "" { + if !strings.Contains(resp.Status, assert.Contains) { + return fmt.Errorf("assertion failed, expected %v to contain %q", resp.Status, assert.Contains) } } + + return nil +} + +func (r *Runner) assertBody(ctx interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response, raw []byte) error { + return nil +} + +func (r *Runner) assertHeaders(ctx interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { return nil } diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index b746a94..59e925b 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "github.com/apiqube/cli/ui" "strings" "github.com/apiqube/cli/ui/cli" @@ -19,21 +20,54 @@ func NewOutput() *Output { } func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { - cli.Infof("Start %s case from %s manifest", caseName, manifest.GetName()) + cli.Infof("Start [%s] case from [%s] manifest", caseName, manifest.GetName()) } func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *interfaces.CaseResult) { if result != nil { - cli.Infof("Finish %s case from %s manifest with next reults\nResult: %s\nSuccess: %v\nStatus Code: %d\nDuration: %s", - caseName, - manifest.GetName(), - result.Name, - result.Success, - result.StatusCode, - result.Duration.String(), + successStyle := cli.SuccessStyle + successText := "yes" + if !result.Success { + successStyle = cli.ErrorStyle + successText = "no" + } + + errorsFormatted := "" + if len(result.Errors) > 0 { + var errorsBuilder strings.Builder + for _, err := range result.Errors { + errorsBuilder.WriteString(fmt.Sprintf("\n- %s", err)) + } + errorsFormatted = fmt.Sprintf("\nErrors: %s", errorsBuilder.String()) + } + + detailsFormatted := "" + if len(result.Details) > 0 { + var detailsBuilder strings.Builder + for key, value := range result.Details { + detailsBuilder.WriteString(fmt.Sprintf("\n- %s: %v", key, value)) + } + detailsFormatted = fmt.Sprintf("\nDetails: %s", detailsBuilder.String()) + } + + cli.LogStyledf( + ui.TypeInfo, + "Finish [%s] case from [%s] manifest with next results\n"+ + "Result: %s\n"+ + "Success: %s\n"+ + "Status Code: %s\n"+ + "Duration: %s%s%s", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + cli.LogPair{Message: result.Name}, + cli.LogPair{Message: successText, Style: &successStyle}, + cli.LogPair{Message: fmt.Sprint(result.StatusCode)}, + cli.LogPair{Message: result.Duration.String()}, + cli.LogPair{Message: errorsFormatted, Style: &cli.ErrorStyle}, + cli.LogPair{Message: detailsFormatted, Style: &cli.TimestampStyle}, ) } else { - cli.Infof("Finish %s case from %s manifest", caseName, manifest.GetName()) + cli.Infof("Finish [%s] case from [%s] manifest", caseName, manifest.GetName()) } } diff --git a/internal/core/runner/executor/base_registry.go b/internal/core/runner/executor/base_registry.go index 2f1fa56..d0927bf 100644 --- a/internal/core/runner/executor/base_registry.go +++ b/internal/core/runner/executor/base_registry.go @@ -10,8 +10,9 @@ import ( var DefaultRegistry = &DefaultExecutorRegistry{ executors: map[string]interfaces.Executor{ - manifests.ValuesManifestKind: executors.NewValuesExecutor(), - manifests.ServerManifestKind: executors.NewServerExecutor(), + manifests.ValuesManifestKind: executors.NewValuesExecutor(), + manifests.ServerManifestKind: executors.NewServerExecutor(), + manifests.HttpTestManifestKind: executors.NewHTTPExecutor(), }, } @@ -23,9 +24,7 @@ type DefaultExecutorRegistry struct { } func NewDefaultExecutorRegistry() *DefaultExecutorRegistry { - return &DefaultExecutorRegistry{ - executors: make(map[string]interfaces.Executor), - } + return DefaultRegistry } func (r *DefaultExecutorRegistry) Register(kind string, exec interfaces.Executor) { diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 86d2fad..b4fb8cf 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -2,6 +2,7 @@ package executors import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -92,6 +93,21 @@ func (e *HTTPExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.M func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c api.HttpCase) error { output := ctx.GetOutput() + + start := time.Now() + caseResult := &interfaces.CaseResult{ + Name: c.Name, + Success: false, + Values: make(map[string]any), + Details: make(map[string]any), + } + + output.StartCase(man, c.Name) + defer func() { + caseResult.Duration = time.Since(start) + output.EndCase(man, c.Name, caseResult) + }() + url := c.Url if url == "" { @@ -106,12 +122,14 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c if body != nil { if err := json.NewEncoder(reqBody).Encode(body); err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to encode request body: %s", err.Error())) return fmt.Errorf("encode body failed: %w", err) } } req, err := http.NewRequest(c.Method, url, reqBody) if err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to create request: %s", err.Error())) return fmt.Errorf("create request failed: %w", err) } @@ -121,41 +139,57 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c timeout := c.Timeout if timeout == 0 { - timeout = 30 * time.Second + timeout = 5 * time.Second } client := &http.Client{Timeout: timeout} resp, err := client.Do(req) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("request timed out")) + return fmt.Errorf("request to %s timed out", url) + } + return fmt.Errorf("http request failed: %w", err) } defer func() { if err = resp.Body.Close(); err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to close response body: %s", err.Error())) output.Logf(interfaces.ErrorLevel, "%s %s response body closed failed\nTarget: %s\nName: %s\nMathod: %s\nReason: %s", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target, c.Name, c.Method, err.Error()) } }() respBody, err := io.ReadAll(resp.Body) if err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to read response body: %s", err.Error())) return fmt.Errorf("read response body failed: %w", err) } - var jsonBody any - if err = json.Unmarshal(respBody, &jsonBody); err != nil { - return fmt.Errorf("parse response body failed: %w", err) - } - if c.Save != nil { - e.extractor.Extract(ctx, man.GetID(), c.HttpCase, resp, respBody, jsonBody) + output.Logf(interfaces.InfoLevel, "%s data extraction for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) + + e.extractor.Extract(ctx, man.GetID(), c.HttpCase, resp, respBody) } if c.Assert != nil { - if err = e.assertor.Assert(ctx, c.Assert, resp, respBody, jsonBody); err != nil { + output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) + + if err = e.assertor.Assert(ctx, c.Assert, resp, respBody); err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("assertion failed: %s", err.Error())) return fmt.Errorf("assert failed: %w", err) } } + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("[1] !assertion! failed: %s", resp.Status)) + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("[2] !assertion! failed: %s", resp.Status)) + + caseResult.Details["Method"] = c.Method + caseResult.Details["StatusCode"] = resp.StatusCode + caseResult.Details["Duration"] = time.Since(start) + + caseResult.StatusCode = resp.StatusCode + caseResult.Success = true output.Logf(interfaces.InfoLevel, "%s HTTP Test %s passed", httpExecutorOutputPrefix, c.Name) return nil diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 2da4886..4b13f68 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -16,14 +16,14 @@ const planRunnerOutputPrefix = "Plan Runner:" var _ interfaces.PlanRunner = (*DefaultPlanRunner)(nil) type DefaultPlanRunner struct { - ExecutorRegistry interfaces.ExecutorRegistry - HooksRunner hooks.Runner + registry interfaces.ExecutorRegistry + hooksRunner hooks.Runner } func NewDefaultPlanRunner(registry interfaces.ExecutorRegistry, hooksRunner hooks.Runner) *DefaultPlanRunner { return &DefaultPlanRunner{ - ExecutorRegistry: registry, - HooksRunner: hooksRunner, + registry: registry, + hooksRunner: hooksRunner, } } @@ -40,7 +40,7 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma output.Logf(interfaces.InfoLevel, "%s starting plan: %s", planRunnerOutputPrefix, planID) if p.Spec.Hooks != nil { - if err = r.HooksRunner.RunHooks(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan before start hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } @@ -50,9 +50,11 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma stageName := stage.Name output.Logf(interfaces.InfoLevel, "%s %s stage starting...", planRunnerOutputPrefix, stageName) - if err = r.HooksRunner.RunHooks(ctx, hooks.BeforeRun, stage.Hooks.BeforeRun); err != nil { - output.Logf(interfaces.ErrorLevel, "%s stage %s before start hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) - return err + if stage.Hooks != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.BeforeRun, stage.Hooks.BeforeRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s before start hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } } var execErr error @@ -62,21 +64,25 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma execErr = r.runManifestsStrict(ctx, stage.Manifests) } - if err = r.HooksRunner.RunHooks(ctx, hooks.AfterRun, stage.Hooks.AfterRun); err != nil { - output.Logf(interfaces.ErrorLevel, "%s stage %s after finish hooks running failed: %s", planRunnerOutputPrefix, stageName, err.Error()) - return err + if stage.Hooks != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.AfterRun, stage.Hooks.AfterRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s after finish hooks running failed: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } } if execErr != nil { output.Logf(interfaces.ErrorLevel, "%s stage %s failed\nReason: %s", planRunnerOutputPrefix, stageName, execErr.Error()) - if err = r.HooksRunner.RunHooks(ctx, hooks.OnFailure, stage.Hooks.OnFailure); err != nil { - output.Logf(interfaces.ErrorLevel, "%s stage %s on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) - return err + if stage.Hooks != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.OnFailure, stage.Hooks.OnFailure); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } } if p.Spec.Hooks != nil { - if err = r.HooksRunner.RunHooks(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan on failure hooks runnin failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return errors.Join(execErr, err) } @@ -85,19 +91,21 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma return execErr } - if err = r.HooksRunner.RunHooks(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { - output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) - return err + if p.Spec.Hooks != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return err + } } } if p.Spec.Hooks != nil { - if err = r.HooksRunner.RunHooks(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan after finish hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } - if err = r.HooksRunner.RunHooks(ctx, hooks.OnSuccess, p.Spec.Hooks.OnSuccess); err != nil { + if err = r.hooksRunner.RunHooks(ctx, hooks.OnSuccess, p.Spec.Hooks.OnSuccess); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } @@ -119,12 +127,12 @@ func (r *DefaultPlanRunner) runManifestsStrict(ctx interfaces.ExecutionContext, return fmt.Errorf("run %s manifest failed: %s", id, err.Error()) } - exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + exec, exists := r.registry.Find(man.GetKind()) if !exists { return fmt.Errorf("no executor found for kind: %s", man.GetKind()) } - output.Logf(interfaces.InfoLevel, "%s %s running manifest using executor for: %s", planRunnerOutputPrefix, id, man.GetKind()) + output.Logf(interfaces.InfoLevel, "%s running %s manifest using %s executor", planRunnerOutputPrefix, id, man.GetKind()) if err = exec.Run(ctx, man); err != nil { return fmt.Errorf("manifest %s failed: %s", id, err.Error()) @@ -154,13 +162,13 @@ func (r *DefaultPlanRunner) runManifestsParallel(ctx interfaces.ExecutionContext return } - exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + exec, exists := r.registry.Find(man.GetKind()) if !exists { errChan <- fmt.Errorf("no executor found for kind: %s", man.GetKind()) return } - output.Logf(interfaces.InfoLevel, "%s %s running manifest using executor for: %s", planRunnerOutputPrefix, id, man.GetKind()) + output.Logf(interfaces.InfoLevel, "%s running %s manifest using %s executor", planRunnerOutputPrefix, id, man.GetKind()) if err = exec.Run(ctx, man); err != nil { errChan <- fmt.Errorf("manifest %s failed: %s", id, err.Error()) diff --git a/internal/core/runner/hooks/hooks.go b/internal/core/runner/hooks/hooks.go index 2a8ec20..8cb9078 100644 --- a/internal/core/runner/hooks/hooks.go +++ b/internal/core/runner/hooks/hooks.go @@ -64,28 +64,28 @@ func (r *DefaultHooksRunner) RegisterHooksHandler(event HookEvent, handler HookH r.entries[event] = append(r.entries[event], handler) } -func (r *DefaultHooksRunner) runBeforeRunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { +func (r *DefaultHooksRunner) runBeforeRunHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []Action) error { output := ctx.GetOutput() output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) return nil } -func (r *DefaultHooksRunner) runAfterRunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { +func (r *DefaultHooksRunner) runAfterRunHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []Action) error { output := ctx.GetOutput() output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) return nil } -func (r *DefaultHooksRunner) runOnSuccessHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { +func (r *DefaultHooksRunner) runOnSuccessHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []Action) error { output := ctx.GetOutput() output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) return nil } -func (r *DefaultHooksRunner) runOnFailureHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { +func (r *DefaultHooksRunner) runOnFailureHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []Action) error { output := ctx.GetOutput() output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) diff --git a/internal/core/runner/pass/runner.go b/internal/core/runner/pass/runner.go index 252a1c2..c3557a7 100644 --- a/internal/core/runner/pass/runner.go +++ b/internal/core/runner/pass/runner.go @@ -11,19 +11,19 @@ func NewRunner() *Runner { return &Runner{} } -func (p *Runner) Apply(ctx interfaces.ExecutionContext, input string, passes []tests.Pass) string { +func (p *Runner) Apply(_ interfaces.ExecutionContext, input string, _ []tests.Pass) string { // заменить плейсхолдеры в URL: {{.token}}, {{.user.id}}, и т.п. // + обрабатывать Pass.Map - return "" + return input // return ReplaceWithStoreValues(ctx, input, passes) } -func (p *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, passes []tests.Pass) map[string]any { +func (p *Runner) ApplyBody(_ interfaces.ExecutionContext, body map[string]any, _ []tests.Pass) map[string]any { // аналогично — пройтись по body и заменить шаблоны return body } -func (p *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, passes []tests.Pass) map[string]string { +func (p *Runner) MapHeaders(_ctx interfaces.ExecutionContext, headers map[string]string, _ []tests.Pass) map[string]string { // заменить плейсхолдеры в заголовках return headers } diff --git a/internal/core/runner/values/extractor.go b/internal/core/runner/values/extractor.go index 2afce84..e2b228c 100644 --- a/internal/core/runner/values/extractor.go +++ b/internal/core/runner/values/extractor.go @@ -17,7 +17,7 @@ func NewExtractor() *Extractor { return &Extractor{} } -func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, c tests.HttpCase, resp *http.Response, raw []byte, _ any) { +func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, c tests.HttpCase, resp *http.Response, body []byte) { group := c.Save.Group saveKey := func(key string) string { @@ -34,7 +34,7 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, // Json for key, jsonPath := range c.Save.Json { - val := gjson.GetBytes(raw, jsonPath).Value() + val := gjson.GetBytes(body, jsonPath).Value() ctx.SetTyped(saveKey(key), val, reflect.TypeOf(val).Kind()) } @@ -47,12 +47,12 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, if c.Save.All || c.Save.Status && c.Save.Body { ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) - ctx.Set(saveKey("body"), string(raw)) + ctx.Set(saveKey("body"), string(body)) } else { if c.Save.Status { ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) } else if c.Save.Body { - ctx.Set(saveKey("body"), string(raw)) + ctx.Set(saveKey("body"), string(body)) } } } diff --git a/ui/cli/console.go b/ui/cli/console.go index dfcbd8a..1e1c61c 100644 --- a/ui/cli/console.go +++ b/ui/cli/console.go @@ -101,3 +101,15 @@ func Done(msg string) { instance.Done(msg) } } + +func LogStyled(level ui.LogLevel, pairs ...LogPair) { + if isEnabled() { + instance.LogStyled(level, pairs...) + } +} + +func LogStyledf(level ui.LogLevel, format string, pairs ...LogPair) { + if isEnabled() { + instance.LogStyledf(level, format, pairs...) + } +} diff --git a/ui/cli/logger.go b/ui/cli/logger.go index 8b01525..ece4673 100644 --- a/ui/cli/logger.go +++ b/ui/cli/logger.go @@ -9,6 +9,11 @@ import ( "github.com/charmbracelet/lipgloss" ) +type LogPair struct { + Message string + Style *lipgloss.Style +} + func (u *UI) Log(level ui.LogLevel, msg string) { fmt.Print(formatLog(level, msg)) } @@ -17,6 +22,80 @@ func (u *UI) Logf(level ui.LogLevel, format string, args ...any) { u.Log(level, fmt.Sprintf(format, args...)) } +func (u *UI) LogStyled(level ui.LogLevel, pairs ...LogPair) { + var logBuilder strings.Builder + + var levelText string + var style lipgloss.Style + + switch level { + case ui.TypeDebug: + levelText = "DEBUG" + style = DebugStyle + case ui.TypeInfo: + levelText = "INFO" + style = InfoStyle + case ui.TypeWarning: + levelText = "WARN" + style = WarningStyle + case ui.TypeError: + levelText = "ERROR" + style = ErrorStyle + case ui.TypeFatal: + levelText = "FATAL" + style = FatalStyle + case ui.TypeSuccess: + levelText = "SUCCESS" + style = SuccessStyle + default: + levelText = "INFO" + style = InfoStyle + } + + paddedLevel := fmt.Sprintf("%-7s", levelText) + levelStyled := style.Render(paddedLevel) + timestamp := TimestampStyle.Render(time.Now().Format("15:04:05")) + + for _, pair := range pairs { + msg := pair.Message + if pair.Style != nil { + msg = pair.Style.Render(msg) + } else { + msg = LogStyle.Render(msg) + } + logBuilder.WriteString(msg) + } + + msg := logBuilder.String() + lines := strings.Split(msg, "\n") + baseIndent := len("14:30:45") + 1 + 7 + 2 + + for i := 1; i < len(lines); i++ { + lines[i] = strings.Repeat(" ", baseIndent) + lines[i] + } + msg = strings.Join(lines, "\n") + + message := LogStyle.Render(msg) + fmt.Printf("%s %s %s\n", timestamp, levelStyled, message) +} + +func (u *UI) LogStyledf(level ui.LogLevel, format string, pairs ...LogPair) { + args := make([]any, len(pairs)) + for i, pair := range pairs { + msg := pair.Message + if pair.Style != nil { + msg = pair.Style.Render(msg) + } else { + msg = LogStyle.Render(msg) + } + args[i] = msg + } + + formattedMsg := fmt.Sprintf(format, args...) + + u.LogStyled(level, LogPair{Message: formattedMsg}) +} + func (u *UI) Error(err error) { u.Log(ui.TypeError, err.Error()) } @@ -32,25 +111,25 @@ func formatLog(level ui.LogLevel, msg string) string { switch level { case ui.TypeDebug: levelText = "DEBUG" - style = debugStyle + style = DebugStyle case ui.TypeInfo: levelText = "INFO" - style = infoStyle + style = InfoStyle case ui.TypeWarning: levelText = "WARN" - style = warningStyle + style = WarningStyle case ui.TypeError: levelText = "ERROR" - style = errorStyle + style = ErrorStyle case ui.TypeFatal: levelText = "FATAL" - style = fatalStyle + style = FatalStyle case ui.TypeSuccess: levelText = "SUCCESS" - style = successStyle + style = SuccessStyle default: levelText = "INFO" - style = infoStyle + style = InfoStyle } paddedLevel := fmt.Sprintf("%-7s", levelText) @@ -64,8 +143,8 @@ func formatLog(level ui.LogLevel, msg string) string { } msg = strings.Join(lines, "\n") - timestamp := timestampStyle.Render(time.Now().Format("15:04:05")) - message := logStyle.Render(msg) + timestamp := TimestampStyle.Render(time.Now().Format("15:04:05")) + message := LogStyle.Render(msg) return fmt.Sprintf("%s %s %s\n", timestamp, levelStyled, message) } diff --git a/ui/cli/styles.go b/ui/cli/styles.go index 3470dbd..b223ef2 100644 --- a/ui/cli/styles.go +++ b/ui/cli/styles.go @@ -3,29 +3,29 @@ package cli import "github.com/charmbracelet/lipgloss" var ( - timestampStyle = lipgloss.NewStyle(). + TimestampStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#949494")) - logStyle = lipgloss.NewStyle(). + LogStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#e4e4e4")) - debugStyle = lipgloss.NewStyle(). + DebugStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#005fff")) - infoStyle = lipgloss.NewStyle(). + InfoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00afff")) - warningStyle = lipgloss.NewStyle(). + WarningStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#ff8700")) - errorStyle = lipgloss.NewStyle(). + ErrorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#d70000")). Bold(true) - fatalStyle = lipgloss.NewStyle(). + FatalStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF0000")). Bold(true) - successStyle = lipgloss.NewStyle(). + SuccessStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#5fd700")) ) From 5afb6a906abe2ac4540689f31c8275ca57745f9a Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 23 May 2025 04:34:56 +0200 Subject: [PATCH 3/8] feat(runner): added http metrics set that possible provide in each http test case in manifest --- examples/simple-http-tests/http_test.yaml | 19 +- internal/core/manifests/kinds/tests/base.go | 1 + internal/core/runner/cli/output.go | 33 ++- .../core/runner/executor/executors/http.go | 24 +- internal/core/runner/metrics/http.go | 253 ++++++++++++++++++ 5 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 internal/core/runner/metrics/http.go diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml index 3b3c418..32b3bd7 100644 --- a/examples/simple-http-tests/http_test.yaml +++ b/examples/simple-http-tests/http_test.yaml @@ -6,12 +6,20 @@ metadata: spec: target: http://localhost:8081 cases: + - name: Get All Users Test method: GET endpoint: /users assert: - target: status equals: 200 + details: + - Request-General + - Response-General + - Request-Content-Length + - Request-Content-Type + - Response-Content-Length + - Response-Status - name: Create New User With Body method: POST @@ -22,6 +30,9 @@ spec: assert: - target: status equals: 201 + details: + - Request-General + - Response-General - name: Always Fail Endpoint Test method: GET @@ -29,8 +40,14 @@ spec: assert: - target: status equals: 500 + details: + - Request-General + - Response-General - name: Slow Endpoint Response Test method: GET endpoint: /slow?delay=2s - timeout: 3s # Delay if request is 2s, if set timeout less it will fail + timeout: 3s # Delay of request is 2s, if set timeout less it will fail + details: + - Request-General + - Response-General diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index 4cc06b4..cf56bb0 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -16,6 +16,7 @@ type HttpCase struct { Pass []Pass `yaml:"pass,omitempty" json:"pass,omitempty" validate:"omitempty,min=1,max=25,dive"` Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"omitempty,duration"` Parallel bool `yaml:"async,omitempty" json:"async,omitempty" validate:"omitempty,boolean"` + Details []string `yaml:"details,omitempty" json:"details,omitempty" validate:"omitempty,min=1,max=100"` } type Assert struct { diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index 59e925b..37cb7df 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -2,9 +2,11 @@ package cli import ( "fmt" - "github.com/apiqube/cli/ui" + "sort" "strings" + "github.com/apiqube/cli/ui" + "github.com/apiqube/cli/ui/cli" "github.com/apiqube/cli/internal/core/manifests" @@ -20,7 +22,11 @@ func NewOutput() *Output { } func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { - cli.Infof("Start [%s] case from [%s] manifest", caseName, manifest.GetName()) + cli.LogStyledf(ui.TypeInfo, + "Start [%s] case from [%s] manifest", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + ) } func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *interfaces.CaseResult) { @@ -44,9 +50,18 @@ func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *i detailsFormatted := "" if len(result.Details) > 0 { var detailsBuilder strings.Builder - for key, value := range result.Details { - detailsBuilder.WriteString(fmt.Sprintf("\n- %s: %v", key, value)) + var keys []string + + for key := range result.Details { + keys = append(keys, key) } + + sort.Strings(keys) + + for _, key := range keys { + detailsBuilder.WriteString(fmt.Sprintf("\n- %s: %v", key, result.Details[key])) + } + detailsFormatted = fmt.Sprintf("\nDetails: %s", detailsBuilder.String()) } @@ -67,7 +82,11 @@ func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *i cli.LogPair{Message: detailsFormatted, Style: &cli.TimestampStyle}, ) } else { - cli.Infof("Finish [%s] case from [%s] manifest", caseName, manifest.GetName()) + cli.LogStyledf(ui.TypeInfo, + "Finish [%s] case from [%s] manifest with next results", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + ) } } @@ -85,6 +104,8 @@ func (o *Output) Log(level interfaces.LogLevel, msg string) { cli.Warning(msg) case interfaces.ErrorLevel: cli.Error(msg) + case interfaces.FatalLevel: + cli.Fatal(msg) default: cli.Info(msg) } @@ -100,6 +121,8 @@ func (o *Output) Logf(level interfaces.LogLevel, format string, args ...any) { cli.Warningf(format, args...) case interfaces.ErrorLevel: cli.Errorf(format, args...) + case interfaces.FatalLevel: + cli.Fatalf(format, args...) default: cli.Infof(format, args...) } diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index b4fb8cf..c2a3774 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -12,6 +12,8 @@ import ( "sync" "time" + metrics "github.com/apiqube/cli/internal/core/runner/metrics" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "github.com/apiqube/cli/internal/core/runner/assert" @@ -102,8 +104,17 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c Details: make(map[string]any), } + var ( + req *http.Request + resp *http.Response + err error + ) + output.StartCase(man, c.Name) + defer func() { + metrics.CollectHTTPMetrics(req, resp, c.Details, caseResult) + caseResult.Duration = time.Since(start) output.EndCase(man, c.Name, caseResult) }() @@ -127,7 +138,7 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c } } - req, err := http.NewRequest(c.Method, url, reqBody) + req, err = http.NewRequest(c.Method, url, reqBody) if err != nil { caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to create request: %s", err.Error())) return fmt.Errorf("create request failed: %w", err) @@ -143,10 +154,10 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c } client := &http.Client{Timeout: timeout} - resp, err := client.Do(req) + resp, err = client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { - caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("request timed out")) + caseResult.Errors = append(caseResult.Errors, "request timed out") return fmt.Errorf("request to %s timed out", url) } @@ -181,13 +192,6 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c } } - caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("[1] !assertion! failed: %s", resp.Status)) - caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("[2] !assertion! failed: %s", resp.Status)) - - caseResult.Details["Method"] = c.Method - caseResult.Details["StatusCode"] = resp.StatusCode - caseResult.Details["Duration"] = time.Since(start) - caseResult.StatusCode = resp.StatusCode caseResult.Success = true output.Logf(interfaces.InfoLevel, "%s HTTP Test %s passed", httpExecutorOutputPrefix, c.Name) diff --git a/internal/core/runner/metrics/http.go b/internal/core/runner/metrics/http.go new file mode 100644 index 0000000..1727358 --- /dev/null +++ b/internal/core/runner/metrics/http.go @@ -0,0 +1,253 @@ +package metrics + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// Request +const ( + RequestGeneral = "Request-General" + RequestHeaders = "Request-Headers" + RequestTiming = "Request-Timing" + + RequestContentLength = "Request-Content-Length" + RequestContentType = "Request-Content-Type" + RequestHost = "Request-Host" + RequestMethod = "Request-Method" + RequestPost = "Request-Post" + RequestProtocol = "Request-Protocol" + RequestQuery = "Request-Query" + RequestRemoteAddr = "Request-Remote-Addr" + RequestURI = "Request-URI" + RequestAuthorization = "Request-Authorization" + RequestCookies = "Request-Cookies" + RequestReferer = "Request-Referer" + RequestUserAgent = "Request-User-Agent" + RequestXForwardedFor = "Request-X-Forwarded-For" + RequestScheme = "Request-Scheme" + RequestPath = "Request-Path" + RequestTime = "Request-Time" +) + +// Response +const ( + ResponseGeneral = "Response-General" + ResponseHeaders = "Response-Headers" + ResponseTiming = "Response-Timing" + + ResponseContentLength = "Response-Content-Length" + ResponseProtocol = "Response-Protocol" + ResponseStatus = "Response-Status" + ResponseStatusCode = "Response-StatusCode" + ResponseContentType = "Response-ContentType" + ResponseTime = "Response-Time" + ResponseLatency = "Response-Latency" + ResponseLocation = "Response-Location" + ResponseCacheControl = "Response-Cache-Control" + ResponseContentEncoding = "Response-Content-Encoding" + ResponseSetCookies = "Response-Set-Cookies" + ResponseTrailers = "Response-Trailers" +) + +var detailsMap = map[string]struct{}{ + RequestGeneral: {}, + RequestHeaders: {}, + RequestTiming: {}, + RequestContentLength: {}, + RequestContentType: {}, + RequestHost: {}, + RequestMethod: {}, + RequestPost: {}, + RequestProtocol: {}, + RequestQuery: {}, + RequestRemoteAddr: {}, + RequestURI: {}, + RequestAuthorization: {}, + RequestCookies: {}, + RequestReferer: {}, + RequestUserAgent: {}, + RequestXForwardedFor: {}, + RequestScheme: {}, + RequestPath: {}, + RequestTime: {}, + + ResponseGeneral: {}, + ResponseHeaders: {}, + ResponseTiming: {}, + ResponseContentLength: {}, + ResponseProtocol: {}, + ResponseStatus: {}, + ResponseStatusCode: {}, + ResponseContentType: {}, + ResponseTime: {}, + ResponseLatency: {}, + ResponseLocation: {}, + ResponseCacheControl: {}, + ResponseContentEncoding: {}, + ResponseSetCookies: {}, + ResponseTrailers: {}, +} + +const unset = "" + +func CollectHTTPMetrics(req *http.Request, resp *http.Response, details []string, result *interfaces.CaseResult) { + if result.Details == nil { + result.Details = make(map[string]any) + } + + for _, detail := range details { + if _, ok := detailsMap[detail]; !ok { + continue + } + + var value any + + // Request metrics + if strings.HasPrefix(detail, "Request") && req != nil { + switch detail { + case RequestGeneral: + value = fmt.Sprintf("%s %s %s", req.Method, req.URL.RequestURI(), req.Proto) + case RequestContentLength: + value = req.ContentLength + if value == 0 { + value = unset + } + case RequestContentType: + value = req.Header.Get("Content-Type") + if value == "" { + value = unset + } + case RequestHost: + value = req.Host + if value == "" { + value = unset + } + case RequestMethod: + value = req.Method + case RequestPost: + if req.Method == "POST" { + if body, err := io.ReadAll(req.Body); err == nil { + value = string(body) + req.Body = io.NopCloser(bytes.NewReader(body)) + } else { + value = "" + } + } else { + value = unset + } + case RequestProtocol: + value = req.Proto + case RequestQuery: + value = req.URL.Query().Encode() + if value == "" { + value = unset + } + case RequestRemoteAddr: + value = req.RemoteAddr + case RequestURI: + value = req.URL.RequestURI() + case RequestAuthorization: + value = req.Header.Get("Authorization") + if value == "" { + value = unset + } + case RequestCookies: + if cookies := req.Cookies(); len(cookies) > 0 { + value = cookies + } else { + value = unset + } + case RequestReferer: + value = req.Header.Get("Referer") + if value == "" { + value = unset + } + case RequestUserAgent: + value = req.Header.Get("User-Agent") + if value == "" { + value = unset + } + case RequestXForwardedFor: + value = req.Header.Get("X-Forwarded-For") + if value == "" { + value = unset + } + case RequestScheme: + if req.URL != nil { + value = req.URL.Scheme + } + if value == "" { + value = unset + } + case RequestPath: + value = req.URL.Path + case RequestTime: + value = time.Now().Format(time.RFC3339) + } + } + + // Response metrics + if strings.HasPrefix(detail, "Response") && resp != nil { + switch detail { + case ResponseGeneral: + value = fmt.Sprintf("%s %d %s", resp.Proto, resp.StatusCode, resp.Status) + case ResponseContentLength: + value = resp.ContentLength + if value == 0 { + value = unset + } + case ResponseProtocol: + value = resp.Proto + case ResponseStatus: + value = resp.Status + case ResponseStatusCode: + value = resp.StatusCode + case ResponseContentType: + value = resp.Header.Get("Content-Type") + if value == "" { + value = unset + } + case ResponseTime: + value = time.Now().Format(time.RFC3339) + case ResponseLatency: + value = result.Duration.String() + case ResponseLocation: + value = resp.Header.Get("Location") + if value == "" { + value = unset + } + case ResponseCacheControl: + value = resp.Header.Get("Cache-Control") + if value == "" { + value = unset + } + case ResponseContentEncoding: + value = resp.Header.Get("Content-Encoding") + if value == "" { + value = unset + } + case ResponseSetCookies: + if len(resp.Cookies()) > 0 { + value = resp.Cookies() + } else { + value = unset + } + case ResponseTrailers: + if len(resp.Trailer) > 0 { + value = resp.Trailer + } else { + value = unset + } + } + } + + result.Details[detail] = value + } +} From a6e99f245a9c84145db70b98f6767aaf99d08c11 Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 23 May 2025 05:33:30 +0200 Subject: [PATCH 4/8] chore(runner): updates and refactors, cli cases output updated --- examples/simple-http-tests/http_test.yaml | 24 +------- internal/core/runner/assert/runner.go | 4 +- internal/core/runner/cli/output.go | 56 ++++++++++++++----- .../core/runner/executor/executors/http.go | 16 +++++- internal/core/runner/executor/plan.go | 55 ++++++++++++++---- internal/core/runner/interfaces/output.go | 20 ------- internal/core/runner/interfaces/result.go | 21 +++++++ ui/cli/logger.go | 8 +++ 8 files changed, 132 insertions(+), 72 deletions(-) diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml index 32b3bd7..4acad12 100644 --- a/examples/simple-http-tests/http_test.yaml +++ b/examples/simple-http-tests/http_test.yaml @@ -4,22 +4,14 @@ metadata: name: simple-test-example namespace: simple-http-tests spec: - target: http://localhost:8081 + target: http://127.0.0.1:8081 cases: - - name: Get All Users Test method: GET endpoint: /users assert: - target: status equals: 200 - details: - - Request-General - - Response-General - - Request-Content-Length - - Request-Content-Type - - Response-Content-Length - - Response-Status - name: Create New User With Body method: POST @@ -30,24 +22,14 @@ spec: assert: - target: status equals: 201 - details: - - Request-General - - Response-General - - name: Always Fail Endpoint Test method: GET - endpoint: /fail + url: http://127.0.0.1:8081/fail assert: - target: status equals: 500 - details: - - Request-General - - Response-General - name: Slow Endpoint Response Test method: GET endpoint: /slow?delay=2s - timeout: 3s # Delay of request is 2s, if set timeout less it will fail - details: - - Request-General - - Response-General + timeout: 3s # Delay of request is 2s, if to set timeout less it will fail diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index d7cec82..3fc2453 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -64,10 +64,10 @@ func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Asser return nil } -func (r *Runner) assertBody(ctx interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response, raw []byte) error { +func (r *Runner) assertBody(_ interfaces.ExecutionContext, _ *tests.Assert, _ *http.Response, _ []byte) error { return nil } -func (r *Runner) assertHeaders(ctx interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { +func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, _ *tests.Assert, _ *http.Response) error { return nil } diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index 37cb7df..9a21b1f 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -38,6 +38,17 @@ func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *i successText = "no" } + assertStyle := cli.SuccessStyle + var assertText string + if result.Assert != "" { + if result.Assert == "no" { + assertText = result.Assert + assertStyle = cli.ErrorStyle + } else { + assertText = result.Assert + } + } + errorsFormatted := "" if len(result.Errors) > 0 { var errorsBuilder strings.Builder @@ -65,22 +76,37 @@ func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *i detailsFormatted = fmt.Sprintf("\nDetails: %s", detailsBuilder.String()) } - cli.LogStyledf( - ui.TypeInfo, - "Finish [%s] case from [%s] manifest with next results\n"+ - "Result: %s\n"+ - "Success: %s\n"+ - "Status Code: %s\n"+ - "Duration: %s%s%s", - cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Finish [%s] case from [%s] manifest with next results", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}.String(), cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, - cli.LogPair{Message: result.Name}, - cli.LogPair{Message: successText, Style: &successStyle}, - cli.LogPair{Message: fmt.Sprint(result.StatusCode)}, - cli.LogPair{Message: result.Duration.String()}, - cli.LogPair{Message: errorsFormatted, Style: &cli.ErrorStyle}, - cli.LogPair{Message: detailsFormatted, Style: &cli.TimestampStyle}, - ) + )) + + builder.WriteString(fmt.Sprintf("\nResult: %s", cli.LogPair{Message: result.Name}.String())) + builder.WriteString(fmt.Sprintf("\nSuccess: %s", cli.LogPair{Message: successText, Style: &successStyle}.String())) + + if assertText != "" { + builder.WriteString(fmt.Sprintf("\nAssert: %s", cli.LogPair{Message: assertText, Style: &assertStyle}.String())) + } + + if result.StatusCode != 0 { + builder.WriteString(fmt.Sprintf("\nStatus Code: %s", cli.LogPair{Message: fmt.Sprint(result.StatusCode)}.String())) + } + + if result.Duration != 0 { + builder.WriteString(fmt.Sprintf("\nDuration: %s", cli.LogPair{Message: result.Duration.String()}.String())) + } + + if len(errorsFormatted) > 0 { + builder.WriteString(cli.LogPair{Message: errorsFormatted, Style: &cli.ErrorStyle}.String()) + } + + if len(detailsFormatted) > 0 { + builder.WriteString(cli.LogPair{Message: detailsFormatted, Style: &cli.TimestampStyle}.String()) + } + + cli.Info(builder.String()) } else { cli.LogStyledf(ui.TypeInfo, "Finish [%s] case from [%s] manifest with next results", diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index c2a3774..2d13298 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -99,7 +99,6 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c start := time.Now() caseResult := &interfaces.CaseResult{ Name: c.Name, - Success: false, Values: make(map[string]any), Details: make(map[string]any), } @@ -122,9 +121,17 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c url := c.Url if url == "" { - url = strings.TrimRight(man.Spec.Target, "/") + "/" + strings.TrimLeft(c.Endpoint, "/") - } + baseUrl := strings.TrimRight(man.Spec.Target, "/") + endpoint := strings.TrimLeft(c.Endpoint, "/") + if baseUrl != "" && endpoint != "" { + url = baseUrl + "/" + endpoint + } else if baseUrl != "" { + url = baseUrl + } else { + url = endpoint + } + } url = e.passer.Apply(ctx, url, c.Pass) headers := e.passer.MapHeaders(ctx, c.Headers, c.Pass) body := e.passer.ApplyBody(ctx, c.Body, c.Pass) @@ -187,9 +194,12 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) if err = e.assertor.Assert(ctx, c.Assert, resp, respBody); err != nil { + caseResult.Assert = "no" caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("assertion failed: %s", err.Error())) return fmt.Errorf("assert failed: %w", err) } + + caseResult.Assert = "yes" } caseResult.StatusCode = resp.StatusCode diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 4b13f68..958c524 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -39,19 +39,29 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma planID := p.GetID() output.Logf(interfaces.InfoLevel, "%s starting plan: %s", planRunnerOutputPrefix, planID) + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before start: %v", planRunnerOutputPrefix, err) + return err + } + if p.Spec.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { + if err = r.runHooksWithContext(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan before start hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } } for _, stage := range p.Spec.Stages { + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before stage %s: %v", planRunnerOutputPrefix, stage.Name, err) + return err + } + stageName := stage.Name output.Logf(interfaces.InfoLevel, "%s %s stage starting...", planRunnerOutputPrefix, stageName) if stage.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.BeforeRun, stage.Hooks.BeforeRun); err != nil { + if err = r.runHooksWithContext(ctx, hooks.BeforeRun, stage.Hooks.BeforeRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s stage %s before start hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) return err } @@ -64,8 +74,13 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma execErr = r.runManifestsStrict(ctx, stage.Manifests) } + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled after stage %s: %v", planRunnerOutputPrefix, stage.Name, err) + return err + } + if stage.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.AfterRun, stage.Hooks.AfterRun); err != nil { + if err = r.runHooksWithContext(ctx, hooks.AfterRun, stage.Hooks.AfterRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s stage %s after finish hooks running failed: %s", planRunnerOutputPrefix, stageName, err.Error()) return err } @@ -75,15 +90,15 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma output.Logf(interfaces.ErrorLevel, "%s stage %s failed\nReason: %s", planRunnerOutputPrefix, stageName, execErr.Error()) if stage.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.OnFailure, stage.Hooks.OnFailure); err != nil { + if err = r.runHooksWithContext(ctx, hooks.OnFailure, stage.Hooks.OnFailure); err != nil { output.Logf(interfaces.ErrorLevel, "%s stage %s on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) return err } } if p.Spec.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { - output.Logf(interfaces.ErrorLevel, "%s plan on failure hooks runnin failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + if err = r.runHooksWithContext(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return errors.Join(execErr, err) } } @@ -91,21 +106,26 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma return execErr } - if p.Spec.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { - output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + if stage.Hooks != nil { + if err = r.runHooksWithContext(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s on success hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) return err } } } + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before final hooks: %v", planRunnerOutputPrefix, err) + return err + } + if p.Spec.Hooks != nil { - if err = r.hooksRunner.RunHooks(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { + if err = r.runHooksWithContext(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan after finish hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } - if err = r.hooksRunner.RunHooks(ctx, hooks.OnSuccess, p.Spec.Hooks.OnSuccess); err != nil { + if err = r.runHooksWithContext(ctx, hooks.OnSuccess, p.Spec.Hooks.OnSuccess); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return err } @@ -194,3 +214,16 @@ func (r *DefaultPlanRunner) runManifestsParallel(ctx interfaces.ExecutionContext return nil } + +func (r *DefaultPlanRunner) runHooksWithContext(ctx interfaces.ExecutionContext, event hooks.HookEvent, actions []hooks.Action) error { + if len(actions) == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + return r.hooksRunner.RunHooks(ctx, event, actions) + } +} diff --git a/internal/core/runner/interfaces/output.go b/internal/core/runner/interfaces/output.go index 15d3668..01e01a8 100644 --- a/internal/core/runner/interfaces/output.go +++ b/internal/core/runner/interfaces/output.go @@ -13,23 +13,3 @@ type Output interface { DumpValues(values map[string]any) Error(err error) } - -type Message struct { - Format string - Values []any -} - -type Progress struct { - Stage string - Step string - Total int - Done int -} - -type MetricResult struct { - Name string - Value float64 - Unit string - Warn float64 - Error float64 -} diff --git a/internal/core/runner/interfaces/result.go b/internal/core/runner/interfaces/result.go index ecd375d..c57940b 100644 --- a/internal/core/runner/interfaces/result.go +++ b/internal/core/runner/interfaces/result.go @@ -7,9 +7,30 @@ import ( type CaseResult struct { Name string Success bool + Assert string StatusCode int Duration time.Duration Errors []string Values map[string]any Details map[string]any } + +type Message struct { + Format string + Values []any +} + +type Progress struct { + Stage string + Step string + Total int + Done int +} + +type MetricResult struct { + Name string + Value float64 + Unit string + Warn float64 + Error float64 +} diff --git a/ui/cli/logger.go b/ui/cli/logger.go index ece4673..847f11e 100644 --- a/ui/cli/logger.go +++ b/ui/cli/logger.go @@ -14,6 +14,14 @@ type LogPair struct { Style *lipgloss.Style } +func (p LogPair) String() string { + if p.Style == nil { + return LogStyle.Render(p.Message) + } + + return p.Style.Render(p.Message) +} + func (u *UI) Log(level ui.LogLevel, msg string) { fmt.Print(formatLog(level, msg)) } From 47ba65bc0117eba5d0e043caf63f3fe7a64a588b Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 23 May 2025 05:49:47 +0200 Subject: [PATCH 5/8] chore(runner): to simple http test yaml added comments, from base test removed redundant struct --- examples/simple-http-tests/http_test.yaml | 56 ++++++++++++++++----- internal/core/manifests/kinds/tests/base.go | 10 +--- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml index 4acad12..9cd53f2 100644 --- a/examples/simple-http-tests/http_test.yaml +++ b/examples/simple-http-tests/http_test.yaml @@ -1,35 +1,67 @@ +# API Test Configuration File +# -------------------------- +# version: Defines the schema version for future compatibility version: v1 + +# kind: Specifies the test type (HttpTest for HTTP API testing) kind: HttpTest + +# Metadata section - Contains identifying information metadata: + # name: Unique identifier for this test suite name: simple-test-example + + # namespace: Logical grouping for organizational purposes namespace: simple-http-tests + +# spec: Main configuration container spec: + # target: Base URL for all test cases (can be overridden per-case) target: http://127.0.0.1:8081 + + # cases: List of test scenarios to execute cases: - - name: Get All Users Test - method: GET - endpoint: /users + # Test Case 1: Basic GET request validation + - name: Get All Users Test # Descriptive test name + method: GET # HTTP method (GET/POST/PUT/etc) + endpoint: /users # Appended to target URL + + # assert: Validation rules assert: - - target: status - equals: 200 + - target: status # What to validate (status code) + equals: 200 # Expected value (HTTP 200 OK) + # Test Case 2: POST request with payload - name: Create New User With Body method: POST endpoint: /users + + # headers: Request headers to include + headers: + Content-Type: application/json # Specifies JSON payload + + # body: Request payload (automatically JSON-encoded) body: - name: user_name - email: user_email@example.com + name: user_name # Example field + email: user_email@example.com # Example field + assert: - target: status - equals: 201 + equals: 201 # HTTP 201 Created + + # Test Case 3: Absolute URL test - name: Always Fail Endpoint Test method: GET - url: http://127.0.0.1:8081/fail + url: http://127.0.0.1:8081/fail # Overrides spec.target assert: - target: status - equals: 500 + equals: 500 # Expecting server error + # Test Case 4: Performance testing - name: Slow Endpoint Response Test method: GET - endpoint: /slow?delay=2s - timeout: 3s # Delay of request is 2s, if to set timeout less it will fail + endpoint: /slow?delay=2s # Test endpoint with artificial delay + timeout: 3s # Fail if response > 3 seconds + assert: + - target: status + equals: 200 # Should still return 200 if within timeout \ No newline at end of file diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index cf56bb0..ee5242f 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -20,15 +20,7 @@ type HttpCase struct { } type Assert struct { - Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,min=3,max=128"` - Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` - Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` - Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` - Template string `yaml:"template,omitempty" json:"template,omitempty" validate:"omitempty,min=1,max=100,contains_template"` -} - -type AssertElement struct { - Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,min=3,max=128"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,oneof=status body headers"` Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` From e2eccdbb9cd6e5e9e0df648678521744ee1c7193 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sat, 24 May 2025 00:35:43 +0200 Subject: [PATCH 6/8] feat(runner): added fake data generator via manifests templates --- examples/simple-http-tests/http_test.yaml | 15 +- go.mod | 1 + go.sum | 2 + .../core/runner/executor/executors/http.go | 1 + internal/core/runner/pass/runner.go | 129 +++++++++++-- internal/core/runner/templates/fake.go | 105 +++++++++++ internal/core/runner/templates/genetator.go | 172 ++++++++++++++++++ internal/core/runner/templates/methods.go | 23 +++ 8 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 internal/core/runner/templates/fake.go create mode 100644 internal/core/runner/templates/genetator.go create mode 100644 internal/core/runner/templates/methods.go diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml index 9cd53f2..c5a3f81 100644 --- a/examples/simple-http-tests/http_test.yaml +++ b/examples/simple-http-tests/http_test.yaml @@ -42,9 +42,13 @@ spec: # body: Request payload (automatically JSON-encoded) body: - name: user_name # Example field - email: user_email@example.com # Example field - + name: "{{ Fake.name }}" # Generate fake name for request + email: "{{ Fake.email }}" # Generate fake email for request + age: "{{ Fake.uint.10.100 }}" # Generate fake positive number between 10 and 100 including + address: + street: "{{ Fake.email }}" + number: "{{ Fake.name }}" + assert: - target: status equals: 201 # HTTP 201 Created @@ -61,7 +65,4 @@ spec: - name: Slow Endpoint Response Test method: GET endpoint: /slow?delay=2s # Test endpoint with artificial delay - timeout: 3s # Fail if response > 3 seconds - assert: - - target: status - equals: 200 # Should still return 200 if within timeout \ No newline at end of file + timeout: 3s # Fail if response > 3 seconds \ No newline at end of file diff --git a/go.mod b/go.mod index b7221f6..cd5040b 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect github.com/blevesearch/zapx/v16 v16.2.3 // indirect + github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect diff --git a/go.sum b/go.sum index e12e80b..2468d11 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFx github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= github.com/blevesearch/zapx/v16 v16.2.3 h1:7Y0r+a3diEvlazsncexq1qoFOcBd64xwMS7aDm4lo1s= github.com/blevesearch/zapx/v16 v16.2.3/go.mod h1:wVJ+GtURAaRG9KQAMNYyklq0egV+XJlGcXNCE0OFjjA= +github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI= +github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 2d13298..4c6e930 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -132,6 +132,7 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c url = endpoint } } + url = e.passer.Apply(ctx, url, c.Pass) headers := e.passer.MapHeaders(ctx, c.Headers, c.Pass) body := e.passer.ApplyBody(ctx, c.Body, c.Pass) diff --git a/internal/core/runner/pass/runner.go b/internal/core/runner/pass/runner.go index c3557a7..8eb29db 100644 --- a/internal/core/runner/pass/runner.go +++ b/internal/core/runner/pass/runner.go @@ -1,29 +1,132 @@ package pass import ( + "fmt" + "regexp" + "strings" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/apiqube/cli/internal/core/runner/templates" ) -type Runner struct{} +type Runner struct { + fakeEngine *templates.TemplateEngine +} func NewRunner() *Runner { - return &Runner{} + return &Runner{ + fakeEngine: templates.New(), + } } -func (p *Runner) Apply(_ interfaces.ExecutionContext, input string, _ []tests.Pass) string { - // заменить плейсхолдеры в URL: {{.token}}, {{.user.id}}, и т.п. - // + обрабатывать Pass.Map - return input - // return ReplaceWithStoreValues(ctx, input, passes) +func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string, pass []tests.Pass) string { + result := input + for _, p := range pass { + if p.Map != nil { + for placeholder, mapKey := range p.Map { + if strings.Contains(result, placeholder) { + if val, ok := ctx.Get(mapKey); ok { + result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", val)) + } + } + } + } + } + + re := regexp.MustCompile(`\{\{\s*([^}\s]+)\s*}}`) + result = re.ReplaceAllStringFunc(result, func(match string) string { + key := strings.Trim(match, "{} \t") + + if val, ok := ctx.Get(key); ok { + return fmt.Sprintf("%v", val) + } + + if strings.HasPrefix(key, "Fake.") { + if val, err := r.fakeEngine.Execute(match); err == nil { + return fmt.Sprintf("%v", val) + } + } + + return match + }) + + return result } -func (p *Runner) ApplyBody(_ interfaces.ExecutionContext, body map[string]any, _ []tests.Pass) map[string]any { - // аналогично — пройтись по body и заменить шаблоны - return body +func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, pass []tests.Pass) map[string]any { + if body == nil { + return nil + } + + result := make(map[string]any, len(body)) + + for key, value := range body { + switch v := value.(type) { + case string: + result[key] = r.renderTemplate(ctx, v) + case map[string]any: + result[key] = r.ApplyBody(ctx, v, pass) + case []any: + result[key] = r.applyArray(ctx, v, pass) + default: + result[key] = v + } + } + + return result } -func (p *Runner) MapHeaders(_ctx interfaces.ExecutionContext, headers map[string]string, _ []tests.Pass) map[string]string { - // заменить плейсхолдеры в заголовках - return headers +func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, pass []tests.Pass) map[string]string { + if headers == nil { + return nil + } + + result := make(map[string]string, len(headers)) + + for key, value := range headers { + processedKey := r.Apply(ctx, key, pass) + processedValue := r.Apply(ctx, value, pass) + result[processedKey] = processedValue + } + + return result +} + +func (r *Runner) renderTemplate(_ interfaces.ExecutionContext, raw string) any { + if !strings.Contains(raw, "{{") { + return raw + } + + if strings.Contains(raw, "Fake") { + result, err := r.fakeEngine.Execute(raw) + if err != nil { + return raw + } + + return result + } + + return raw +} + +func (r *Runner) applyArray(ctx interfaces.ExecutionContext, arr []any, pass []tests.Pass) []any { + if arr == nil { + return nil + } + + result := make([]any, len(arr)) + for i, item := range arr { + switch v := item.(type) { + case string: + result[i] = r.renderTemplate(ctx, v) + case map[string]any: + result[i] = r.ApplyBody(ctx, v, pass) + case []any: + result[i] = r.applyArray(ctx, v, pass) + default: + result[i] = v + } + } + return result } diff --git a/internal/core/runner/templates/fake.go b/internal/core/runner/templates/fake.go new file mode 100644 index 0000000..32e133b --- /dev/null +++ b/internal/core/runner/templates/fake.go @@ -0,0 +1,105 @@ +package templates + +import ( + "math" + "strconv" + "strings" + + "github.com/brianvoe/gofakeit/v7" +) + +func fakeName(_ ...string) (any, error) { + return gofakeit.Name(), nil +} + +func fakeEmail(_ ...string) (any, error) { + return gofakeit.Email(), nil +} + +func fakePassword(args ...string) (any, error) { + length := 12 + lower := true + upper := true + numeric := true + special := false + space := false + + for _, arg := range args { + arg = strings.ToLower(strings.TrimSpace(arg)) + + switch arg { + case "nolower": + lower = false + case "noupper": + upper = false + case "nonumeric": + numeric = false + case "special": + special = true + case "space": + space = true + default: + if n, err := strconv.Atoi(arg); err == nil { + length = n + } + } + } + + return gofakeit.Password(lower, upper, numeric, special, space, length), nil +} + +func fakeInt(args ...string) (any, error) { + minInt, maxInt := -1_000_000_000_000_000, 1_000_000_000_000_000 + + if len(args) > 0 { + n, err := strconv.ParseInt(args[0], 10, 64) + if err == nil { + minInt = int(n) + } + + if len(args) > 1 { + n, err = strconv.ParseInt(args[1], 10, 64) + if err == nil { + maxInt = int(n) + } + } + } + + if minInt >= maxInt { + minInt, maxInt = maxInt, minInt-1 + } + + return gofakeit.IntRange(minInt, maxInt), nil +} + +func fakeUint(args ...string) (any, error) { + var minInt, maxInt uint64 = 0, math.MaxUint + + if len(args) > 0 { + n, err := strconv.ParseUint(args[0], 10, 64) + if err == nil { + minInt = n + } + + if len(args) > 1 { + n, err = strconv.ParseUint(args[1], 10, 64) + if err == nil { + maxInt = n + } + } + } + + if minInt >= maxInt { + minInt, maxInt = maxInt, minInt + } + + return gofakeit.UintRange(uint(minInt-1), uint(maxInt+1)), nil +} + +func fakeFloat(_ ...string) (any, error) { + return gofakeit.Float64(), nil +} + +func fakeBool(_ ...string) (any, error) { + return gofakeit.Bool(), nil +} diff --git a/internal/core/runner/templates/genetator.go b/internal/core/runner/templates/genetator.go new file mode 100644 index 0000000..ba5617b --- /dev/null +++ b/internal/core/runner/templates/genetator.go @@ -0,0 +1,172 @@ +package templates + +import ( + "fmt" + "regexp" + "strings" + "sync" +) + +type TemplateEngine struct { + generators map[string]GeneratorFunc + methods map[string]MethodFunc + mu sync.RWMutex + cache *sync.Map +} + +type GeneratorFunc func(args ...string) (any, error) + +type MethodFunc func(value any, args ...string) (any, error) + +func New() *TemplateEngine { + engine := &TemplateEngine{ + generators: make(map[string]GeneratorFunc), + methods: make(map[string]MethodFunc), + cache: &sync.Map{}, + } + + engine.RegisterGenerator("Fake.name", fakeName) + engine.RegisterGenerator("Fake.email", fakeEmail) + engine.RegisterGenerator("Fake.password", fakePassword) + engine.RegisterGenerator("Fake.int", fakeInt) + engine.RegisterGenerator("Fake.uint", fakeUint) + engine.RegisterGenerator("Fake.float", fakeFloat) + engine.RegisterGenerator("Fake.bool", fakeBool) + + engine.RegisterMethod("ToUpper", methodToUpper) + engine.RegisterMethod("ToLower", methodToLower) + engine.RegisterMethod("Trim", methodTrim) + + return engine +} + +func (e *TemplateEngine) RegisterGenerator(name string, fn GeneratorFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.generators[name] = fn +} + +func (e *TemplateEngine) RegisterMethod(name string, fn MethodFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.methods[name] = fn +} + +func (e *TemplateEngine) Execute(template string) (any, error) { + if isPureDirective(template) { + directive := extractDirective(template) + return e.processDirective(directive) + } + + re := regexp.MustCompile(`\{\{\s*(.*?)\s*}}`) + var result strings.Builder + lastIndex := 0 + + for _, match := range re.FindAllStringSubmatchIndex(template, -1) { + result.WriteString(template[lastIndex:match[0]]) + + inner := strings.Trim(template[match[2]:match[3]], " \t") + val, err := e.processDirective(inner) + if err != nil { + return nil, fmt.Errorf("template error: %v", err) + } + + result.WriteString(fmt.Sprintf("%v", val)) + lastIndex = match[1] + } + + result.WriteString(template[lastIndex:]) + + return result.String(), nil +} + +func isPureDirective(template string) bool { + trimmed := strings.TrimSpace(template) + if !strings.HasPrefix(trimmed, "{{") || !strings.HasSuffix(trimmed, "}}") { + return false + } + + content := trimmed[2 : len(trimmed)-2] + return !strings.Contains(content, "{{") && !strings.Contains(content, "}}") +} + +func extractDirective(template string) string { + return strings.TrimSpace(template[2 : len(template)-2]) +} + +func (e *TemplateEngine) processDirective(directive string) (any, error) { + if val, ok := e.cache.Load(directive); ok { + return val, nil + } + + parts := strings.Split(directive, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid directive format") + } + + var value any + var err error + processed := 0 + + for i := 0; i < len(parts); i++ { + current := strings.Join(parts[:i+1], ".") + if generator, ok := e.getGenerator(current); ok { + args := parts[i+1:] + var genArgs []string + for j := 0; j < len(args); j++ { + if strings.Contains(args[j], "(") { + break + } + genArgs = append(genArgs, args[j]) + } + value, err = generator(genArgs...) + if err != nil { + return nil, err + } + processed = i + len(genArgs) + 1 + break + } + } + + if value == nil { + return nil, fmt.Errorf("no generator found for directive") + } + + for i := processed; i < len(parts); i++ { + if strings.Contains(parts[i], "(") { + methodName := strings.Split(parts[i], "(")[0] + method, ok := e.getMethod(methodName) + if !ok { + return nil, fmt.Errorf("unknown method: %s", methodName) + } + + argsStr := strings.TrimSuffix(strings.SplitN(parts[i], "(", 2)[1], ")") + args := strings.Split(argsStr, ",") + for j := range args { + args[j] = strings.TrimSpace(args[j]) + } + + value, err = method(value, args...) + if err != nil { + return nil, err + } + } + } + + e.cache.Store(directive, value) + return value, nil +} + +func (e *TemplateEngine) getGenerator(name string) (GeneratorFunc, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + fn, ok := e.generators[name] + return fn, ok +} + +func (e *TemplateEngine) getMethod(name string) (MethodFunc, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + fn, ok := e.methods[name] + return fn, ok +} diff --git a/internal/core/runner/templates/methods.go b/internal/core/runner/templates/methods.go new file mode 100644 index 0000000..24c040f --- /dev/null +++ b/internal/core/runner/templates/methods.go @@ -0,0 +1,23 @@ +package templates + +import ( + "fmt" + "strings" +) + +func methodToUpper(value any, args ...string) (any, error) { + return strings.ToUpper(fmt.Sprintf("%v", value)), nil +} + +func methodToLower(value any, args ...string) (any, error) { + return strings.ToLower(fmt.Sprintf("%v", value)), nil +} + +func methodTrim(value any, args ...string) (any, error) { + cutset := " \t\n\r" + if len(args) > 0 { + cutset = args[0] + } + + return strings.Trim(fmt.Sprintf("%v", value), cutset), nil +} From d355f69921a42b6f59b65a5d4929a107ccba9988 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sat, 24 May 2025 01:40:45 +0200 Subject: [PATCH 7/8] feat(runner): added assertion for headers and body of HTTP responses --- examples/simple-http-tests/http_test.yaml | 13 ++- internal/core/manifests/kinds/tests/base.go | 2 +- internal/core/runner/assert/runner.go | 101 +++++++++++++++++- .../core/runner/executor/executors/http.go | 8 +- internal/core/runner/{pass => form}/runner.go | 2 +- internal/core/runner/templates/methods.go | 4 +- 6 files changed, 115 insertions(+), 15 deletions(-) rename internal/core/runner/{pass => form}/runner.go (99%) diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml index c5a3f81..c06e57c 100644 --- a/examples/simple-http-tests/http_test.yaml +++ b/examples/simple-http-tests/http_test.yaml @@ -48,12 +48,19 @@ spec: address: street: "{{ Fake.email }}" number: "{{ Fake.name }}" - assert: - target: status equals: 201 # HTTP 201 Created - # Test Case 3: Absolute URL test + # Test Case 3: Getting and validating a user + - name: Get User By ID Test + method: GET + endpoint: /users/1 # Endpoint with user ID + assert: + - target: status + equals: 200 + + # Test Case 4: Absolute URL test - name: Always Fail Endpoint Test method: GET url: http://127.0.0.1:8081/fail # Overrides spec.target @@ -61,7 +68,7 @@ spec: - target: status equals: 500 # Expecting server error - # Test Case 4: Performance testing + # Test Case 5: Performance testing - name: Slow Endpoint Response Test method: GET endpoint: /slow?delay=2s # Test endpoint with artificial delay diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index ee5242f..c74e4fd 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -21,7 +21,7 @@ type HttpCase struct { type Assert struct { Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,oneof=status body headers"` - Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` + Equals any `yaml:"equals,omitempty" json:"equals,omitempty" validate:"omitempty,min=1,max=100"` Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` Template string `yaml:"template,omitempty" json:"template,omitempty" validate:"omitempty,min=1,max=100,contains_template"` diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 3fc2453..f7bc2c4 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -1,10 +1,15 @@ package assert import ( + "bytes" + "encoding/json" "fmt" "net/http" + "reflect" "strings" + "github.com/apiqube/cli/internal/core/runner/templates" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -21,10 +26,14 @@ func (t Type) String() string { return string(t) } -type Runner struct{} +type Runner struct { + templateEngine *templates.TemplateEngine +} func NewRunner() *Runner { - return &Runner{} + return &Runner{ + templateEngine: templates.New(), + } } func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert, resp *http.Response, body []byte) error { @@ -64,10 +73,94 @@ func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Asser return nil } -func (r *Runner) assertBody(_ interfaces.ExecutionContext, _ *tests.Assert, _ *http.Response, _ []byte) error { +func (r *Runner) assertBody(_ interfaces.ExecutionContext, assert *tests.Assert, _ *http.Response, body []byte) error { + if assert.Exists { + if len(body) == 0 { + return fmt.Errorf("assertion failed, expected not null body") + } + + return nil + } + + if assert.Template != "" { + tplResult, err := r.templateEngine.Execute(assert.Template) + if err != nil { + return fmt.Errorf("template execution error: %v", err) + } + + expected, err := json.Marshal(tplResult) + if err != nil { + return fmt.Errorf("template marshal error: %v", err) + } + + if !reflect.DeepEqual(body, expected) { + return fmt.Errorf("body doesn't match template\nexpected: %s\nactual: %s", expected, body) + } + + return nil + } + + if assert.Equals != nil { + var expected any + if err := json.Unmarshal([]byte(fmt.Sprintf("%v", assert.Equals)), &expected); err != nil { + return fmt.Errorf("invalid Equals in body target value: %v", err) + } + + if !reflect.DeepEqual(body, expected) { + return fmt.Errorf("assert failed, expected body %v to equal %v", string(body), expected) + } + return nil + } + + if assert.Contains != "" { + if !bytes.Contains(body, []byte(assert.Contains)) { + return fmt.Errorf("assertion failed, expected %v in body", assert.Contains) + } + + return nil + } + return nil } -func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, _ *tests.Assert, _ *http.Response) error { +func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { + if assert.Equals != nil { + if equals, ok := assert.Equals.(map[string]any); ok { + return fmt.Errorf("assertion failed, expected map assertion got %v", assert.Equals) + } else { + for key, expectedVal := range equals { + actualVal := resp.Header.Get(key) + if fmt.Sprintf("%v", expectedVal) != actualVal { + return fmt.Errorf("assertion failed, expected header value %v, got %v", expectedVal, actualVal) + } + } + } + } + + if assert.Contains != "" { + found := false + for _, values := range resp.Header { + for _, val := range values { + if strings.Contains(val, assert.Contains) { + found = true + break + } + } + if found { + break + } + } + + if !found { + return fmt.Errorf("assertion failed, expected header %v but not found", assert.Contains) + } + } + + if assert.Exists { + if len(resp.Header) == 0 { + return fmt.Errorf("assertion failed, expected some headers in response") + } + } + return nil } diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 4c6e930..6337e9c 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -12,13 +12,13 @@ import ( "sync" "time" - metrics "github.com/apiqube/cli/internal/core/runner/metrics" + "github.com/apiqube/cli/internal/core/runner/metrics" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "github.com/apiqube/cli/internal/core/runner/assert" + "github.com/apiqube/cli/internal/core/runner/form" "github.com/apiqube/cli/internal/core/runner/interfaces" - "github.com/apiqube/cli/internal/core/runner/pass" "github.com/apiqube/cli/internal/core/runner/values" ) @@ -30,7 +30,7 @@ type HTTPExecutor struct { client *http.Client extractor *values.Extractor assertor *assert.Runner - passer *pass.Runner + passer *form.Runner } func NewHTTPExecutor() *HTTPExecutor { @@ -38,7 +38,7 @@ func NewHTTPExecutor() *HTTPExecutor { client: &http.Client{Timeout: 30 * time.Second}, extractor: values.NewExtractor(), assertor: assert.NewRunner(), - passer: pass.NewRunner(), + passer: form.NewRunner(), } } diff --git a/internal/core/runner/pass/runner.go b/internal/core/runner/form/runner.go similarity index 99% rename from internal/core/runner/pass/runner.go rename to internal/core/runner/form/runner.go index 8eb29db..55937eb 100644 --- a/internal/core/runner/pass/runner.go +++ b/internal/core/runner/form/runner.go @@ -1,4 +1,4 @@ -package pass +package form import ( "fmt" diff --git a/internal/core/runner/templates/methods.go b/internal/core/runner/templates/methods.go index 24c040f..3e12e06 100644 --- a/internal/core/runner/templates/methods.go +++ b/internal/core/runner/templates/methods.go @@ -5,11 +5,11 @@ import ( "strings" ) -func methodToUpper(value any, args ...string) (any, error) { +func methodToUpper(value any, _ ...string) (any, error) { return strings.ToUpper(fmt.Sprintf("%v", value)), nil } -func methodToLower(value any, args ...string) (any, error) { +func methodToLower(value any, _ ...string) (any, error) { return strings.ToLower(fmt.Sprintf("%v", value)), nil } From d771dfd6cedb9711c7680e4c2d737a035a79c4ef Mon Sep 17 00:00:00 2001 From: Nofre Date: Sat, 24 May 2025 01:43:15 +0200 Subject: [PATCH 8/8] fix(mod): go mod tided --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cd5040b..780b7e5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 github.com/blevesearch/bleve/v2 v2.5.1 + github.com/brianvoe/gofakeit/v7 v7.2.1 github.com/charmbracelet/lipgloss v1.1.0 github.com/dgraph-io/badger/v4 v4.7.0 github.com/go-playground/validator/v10 v10.26.0 @@ -40,7 +41,6 @@ require ( github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect github.com/blevesearch/zapx/v16 v16.2.3 // indirect - github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect