diff --git a/.gitignore b/.gitignore index 95ceb91..490633f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ *.so *.dylib bin +.qodo +cover.svg # Test binary, built with `go test -c` *.test diff --git a/.goreleaser.yml b/.goreleaser.yml index 196fc60..c314323 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,12 +1,14 @@ version: 2 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + builds: - main: ./cmd/qube id: "qube" binary: qube ldflags: - - -s -w -X github.com/apiqube/cli/cmd/cli.Version={{.Version}} - - -s -w -X github.com/apiqube/cli/cmd/cli.Commit={{.ShortCommit}} - - -s -w -X github.com/apiqube/cli/cmd/cli.Date={{.Date}} + - -s -w -X github.com/apiqube/cli/cmd/cli.Version={{.Version}} -X github.com/apiqube/cli/cmd/cli.Commit={{.ShortCommit}} -X github.com/apiqube/cli/cmd/cli.Date={{.Date}} env: - CGO_ENABLED=0 goos: [linux, darwin, windows] diff --git a/Taskfile.yml b/Taskfile.yml index e0c532a..e3af921 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -45,6 +45,11 @@ tasks: cmds: - go test -v -coverpkg=./... -coverprofile=cover.out ./... + cover: + desc: Create SVG cover heatmap from cover.out + cmds: + - go-cover-treemap -percent=true -w=1080 -h=360 -coverprofile cover.out > cover.svg + fmt: desc: 🧹 Cleaning all go code cmds: diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go index 2034c9b..0d46e5d 100644 --- a/cmd/cli/apply/apply.go +++ b/cmd/cli/apply/apply.go @@ -11,8 +11,8 @@ import ( "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/internal/validate" "github.com/apiqube/cli/ui/cli" + "github.com/goccy/go-yaml" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) func init() { @@ -124,5 +124,5 @@ func printPostApplySummary(mans []manifests.Manifest) { } func indentYAMLError(err *yaml.TypeError) string { - return " " + strings.Join(err.Errors, "\n ") + return fmt.Sprintf(" %s\n ", err.Error()) } diff --git a/cmd/cli/generator/generate.go b/cmd/cli/generator/generate.go index 90c6050..171cb6e 100644 --- a/cmd/cli/generator/generate.go +++ b/cmd/cli/generator/generate.go @@ -10,8 +10,8 @@ import ( "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/internal/operations" "github.com/apiqube/cli/ui/cli" + "github.com/goccy/go-yaml" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var Cmd = &cobra.Command{ diff --git a/examples/simple-http-tests-1/http_test.yaml b/examples/complex-http-tests/http_test.yaml similarity index 73% rename from examples/simple-http-tests-1/http_test.yaml rename to examples/complex-http-tests/http_test.yaml index 7247d12..58bebcc 100644 --- a/examples/simple-http-tests-1/http_test.yaml +++ b/examples/complex-http-tests/http_test.yaml @@ -1,8 +1,8 @@ version: v1 kind: HttpTest metadata: - name: simple-test-example-1 - namespace: simple-http-tests-1 + name: test-example + namespace: complex-http-tests spec: target: http://127.0.0.1:8081 @@ -23,4 +23,11 @@ spec: age: "{{ Fake.uint.10.100 }}" address: street: "{{ Fake.address }}" - number: "{{ Regex(\"^[a-z]{5,10}@[a-z]{5,10}\\.(com|net|org)$\") }}" \ No newline at end of file + number: "{{ Regex(\"^[a-z]{5,10}@[a-z]{5,10}\\.(com|net|org)$\") }}" + save: + request: + body: + users: "*" + response: + body: + data: "*" \ No newline at end of file diff --git a/go.mod b/go.mod index 7c50bba..e3095c9 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( 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 + github.com/goccy/go-json v0.10.5 + github.com/goccy/go-yaml v1.18.0 github.com/google/uuid v1.6.0 github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.9.1 @@ -16,7 +18,6 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 golang.org/x/text v0.23.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -98,4 +99,5 @@ require ( golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2468d11..6381078 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,10 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= diff --git a/internal/core/io/write.go b/internal/core/io/write.go index 5de9278..6ad1f50 100644 --- a/internal/core/io/write.go +++ b/internal/core/io/write.go @@ -1,14 +1,15 @@ package io import ( - "encoding/json" "fmt" "os" "path/filepath" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/operations" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) func WriteCombined(path string, format operations.ParseFormat, mans ...manifests.Manifest) error { diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 812e7c2..cc21916 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -2,17 +2,17 @@ package assert import ( "bytes" - "encoding/json" "errors" "fmt" "net/http" "reflect" "strings" - "github.com/apiqube/cli/internal/core/runner/templates" + "github.com/goccy/go-json" "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 Type string @@ -37,115 +37,108 @@ func NewRunner() *Runner { } } +// Assert runs all assertions and aggregates errors. func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert, resp *http.Response, body []byte) error { var err error - - for _, assert := range asserts { - switch assert.Target { + for _, a := range asserts { + switch a.Target { case Status.String(): - err = errors.Join(err, r.assertStatus(ctx, assert, resp)) + err = errors.Join(err, r.assertStatus(ctx, a, resp)) case Body.String(): - err = errors.Join(err, r.assertBody(ctx, assert, resp, body)) + err = errors.Join(err, r.assertBody(ctx, a, resp, body)) case Headers.String(): - err = errors.Join(err, r.assertHeaders(ctx, assert, resp)) + err = errors.Join(err, r.assertHeaders(ctx, a, resp)) default: - return fmt.Errorf("assert failed: unknown assert target %s", assert.Target) + return fmt.Errorf("assert failed: unknown assert target %s", a.Target) } } - return err } -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("expected status type [int] got %T", assert.Equals) +func (r *Runner) assertStatus(_ interfaces.ExecutionContext, a *tests.Assert, resp *http.Response) error { + if a.Equals != nil { + expectedCode, err := toInt(a.Equals) + if err != nil { + return fmt.Errorf("expected status type [int] got %T", a.Equals) } - if resp.StatusCode != expectedCode { return fmt.Errorf("expected status code %v, got %v", expectedCode, resp.StatusCode) } } - - if assert.Contains != "" { - if !strings.Contains(resp.Status, assert.Contains) { - return fmt.Errorf("expected %v to contain %q", resp.Status, assert.Contains) + if a.Contains != "" { + if !strings.Contains(resp.Status, a.Contains) { + return fmt.Errorf("expected %v to contain %q", resp.Status, a.Contains) } } - return nil } -func (r *Runner) assertBody(_ interfaces.ExecutionContext, assert *tests.Assert, _ *http.Response, body []byte) error { - if assert.Exists { +func (r *Runner) assertBody(_ interfaces.ExecutionContext, a *tests.Assert, _ *http.Response, body []byte) error { + if a.Exists { if len(body) == 0 { return fmt.Errorf("expected not null body") } - return nil } - - if assert.Template != "" { - tplResult, err := r.templateEngine.Execute(assert.Template) + if a.Template != "" { + tplResult, err := r.templateEngine.Execute(a.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) { + if !bytes.Equal(body, expected) { return fmt.Errorf("body doesn't match template\nexpected: %s\nactual: %s", expected, body) } - return nil } - - if assert.Equals != nil { + if a.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) + // Try to unmarshal as JSON, fallback to string compare + if err := json.Unmarshal([]byte(fmt.Sprintf("%v", a.Equals)), &expected); err == nil { + var actual any + if marshalErr := json.Unmarshal(body, &actual); marshalErr == nil { + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("expected body %v to equal %v", string(body), expected) + } + return nil + } } - - if !reflect.DeepEqual(body, expected) { - return fmt.Errorf("expected body %v to equal %v", string(body), expected) + // Fallback: compare as string + if string(body) != fmt.Sprint(a.Equals) { + return fmt.Errorf("expected body %v to equal %v", string(body), a.Equals) } return nil } - - if assert.Contains != "" { - if !bytes.Contains(body, []byte(assert.Contains)) { - return fmt.Errorf("expected '%v' in body", assert.Contains) + if a.Contains != "" { + if !bytes.Contains(body, []byte(a.Contains)) { + return fmt.Errorf("expected '%v' in body", a.Contains) } - return nil } - return nil } -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("expected map type assertion got %T", assert.Equals) - } else { - for key, expectedVal := range equals { - actualVal := resp.Header.Get(key) - if fmt.Sprintf("%v", expectedVal) != actualVal { - return fmt.Errorf("expected header value %v, got %v", expectedVal, actualVal) - } +func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, a *tests.Assert, resp *http.Response) error { + if a.Equals != nil { + equals, ok := a.Equals.(map[string]any) + if !ok { + return fmt.Errorf("expected map[string]any for header equals, got %T", a.Equals) + } + for key, expectedVal := range equals { + actualVal := resp.Header.Get(key) + if fmt.Sprintf("%v", expectedVal) != actualVal { + return fmt.Errorf("expected header %v value %v, got %v", key, expectedVal, actualVal) } } } - - if assert.Contains != "" { + if a.Contains != "" { found := false for _, values := range resp.Header { for _, val := range values { - if strings.Contains(val, assert.Contains) { + if strings.Contains(val, a.Contains) { found = true break } @@ -154,17 +147,30 @@ func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, assert *tests.Asse break } } - if !found { - return fmt.Errorf("expected header contains %v but not found", assert.Contains) + return fmt.Errorf("expected header contains %v but not found", a.Contains) } } - - if assert.Exists { + if a.Exists { if len(resp.Header) == 0 { return fmt.Errorf("expected some headers in response") } } - return nil } + +// toInt tries to convert interface{} to int for status code assertions. +func toInt(val any) (int, error) { + switch v := val.(type) { + case uint: + return int(v), nil + case uint64: + return int(v), nil + case int64: + return int(v), nil + case int: + return v, nil + default: + return 0, fmt.Errorf("cannot convert %T to int", val) + } +} diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index e0d6239..70de753 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -3,28 +3,28 @@ package executors import ( "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "strings" "sync" "time" - "github.com/apiqube/cli/internal/core/runner/metrics" + "github.com/goccy/go-json" "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/metrics" "github.com/apiqube/cli/internal/core/runner/save" ) -const httpExecutorOutputPrefix = "HTTP Executor:" - -const httpExecutorRunTimeout = time.Second * 30 +const ( + httpExecutorOutputPrefix = "HTTP Executor:" + httpExecutorRunTimeout = time.Second * 30 +) var _ interfaces.Executor = (*HTTPExecutor)(nil) @@ -45,9 +45,6 @@ func NewHTTPExecutor() *HTTPExecutor { } func (e *HTTPExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { - _ = ctx.GetOutput() - var err error - select { case <-ctx.Done(): return fmt.Errorf("%s run cancelled, run context was canceled", httpExecutorOutputPrefix) @@ -60,38 +57,35 @@ func (e *HTTPExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.M } var wg sync.WaitGroup - errs := make(chan error, len(httpMan.Spec.Cases)) + errCh := make(chan error, len(httpMan.Spec.Cases)) for _, c := range httpMan.Spec.Cases { testCase := c if testCase.Parallel { wg.Add(1) - go func() { + go func(tc api.HttpCase) { defer wg.Done() - var caseErr error - if caseErr = e.runCase(ctx, httpMan, testCase); err != nil { - errs <- caseErr + if err := e.runCase(ctx, httpMan, tc); err != nil { + errCh <- err } - }() + }(testCase) } else { - if err = e.runCase(ctx, httpMan, testCase); err != nil { + if err := e.runCase(ctx, httpMan, testCase); err != nil { return err } } } wg.Wait() - close(errs) + close(errCh) var rErr error - if len(errs) > 0 { - for er := range errs { - rErr = errors.Join(rErr, er) - } - + for er := range errCh { + rErr = errors.Join(rErr, er) + } + if rErr != nil { return rErr } - return nil } @@ -105,29 +99,27 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c } var ( - req *http.Request - resp *http.Response - err error + req *http.Request + resp *http.Response + reqBody = &bytes.Buffer{} + respBody = &bytes.Buffer{} + err error ) + var reqBodyCopy []byte output.StartCase(man, c.Name) - defer func() { metrics.CollectHTTPMetrics(req, resp, c.Details, caseResult) + e.extractor.Extract(ctx, man, c.HttpCase, resp, reqBodyCopy, respBody.Bytes(), caseResult) output.EndCase(man, c.Name, caseResult) }() - // Building url to testing target url := buildHttpURL(c.Url, man.Spec.Target, c.Endpoint) - - // Applying save from Pass declaration 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) - reqBody := &bytes.Buffer{} - 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())) @@ -135,6 +127,7 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c } } + reqBodyCopy = reqBody.Bytes() 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())) @@ -145,57 +138,49 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c req.Header.Set(k, v) } + client := e.client if c.Timeout > 0 { - e.client.Timeout = c.Timeout + client = &http.Client{Timeout: c.Timeout} + caseResult.Details["timeout"] = c.Timeout } start := time.Now() - resp, err = e.client.Do(req) + resp, err = client.Do(req) caseResult.Duration = time.Since(start) - if err != nil { if errors.Is(err, context.DeadlineExceeded) { caseResult.Errors = append(caseResult.Errors, "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()) + output.Logf(interfaces.ErrorLevel, "%s %s response body close failed\nTarget: %s\nName: %s\nMethod: %s\nReason: %s", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target, c.Name, c.Method, err.Error()) } }() - respBody, err := io.ReadAll(resp.Body) + _, err = respBody.ReadFrom(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) } - e.extractor.Extract(ctx, man, c.HttpCase, resp, reqBody.Bytes(), respBody, caseResult) - if c.Save != nil { - output.Logf(interfaces.InfoLevel, "%s data extraction for %s %s", httpExecutorOutputPrefix, man.GetName(), c.Name) - } - if c.Assert != nil { - output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s", httpExecutorOutputPrefix, man.GetName(), c.Name) - - if err = e.assertor.Assert(ctx, c.Assert, resp, respBody); err != nil { + output.Logf(interfaces.InfoLevel, "%s response asserting for %s %s", httpExecutorOutputPrefix, man.GetName(), c.Name) + if err = e.assertor.Assert(ctx, c.Assert, resp, respBody.Bytes()); 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 caseResult.Success = true output.Logf(interfaces.InfoLevel, "%s HTTP Test %s passed", httpExecutorOutputPrefix, c.Name) - return nil } @@ -203,7 +188,6 @@ func buildHttpURL(url, target, endpoint string) string { if url == "" { baseUrl := strings.TrimRight(target, "/") ep := strings.TrimLeft(endpoint, "/") - if baseUrl != "" && ep != "" { url = baseUrl + "/" + ep } else if baseUrl != "" { @@ -212,6 +196,5 @@ func buildHttpURL(url, target, endpoint string) string { url = ep } } - return url } diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 37b105c..7d049c0 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -5,6 +5,8 @@ import ( "fmt" "sync" + "github.com/apiqube/cli/internal/report" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/runner/hooks" @@ -131,6 +133,20 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma } } + // TODO: TEMPL CODE HERE !!! + htmlReportGenerator, err := report.NewHTMLReportGenerator() + if err != nil { + fmt.Println("ERROR:", err) + return nil + } + + reporter := report.NewReportService(htmlReportGenerator) + if err = reporter.GenerateReports(ctx); err != nil { + fmt.Println("ERROR:", err) + return nil + } + // TODO: END + return nil } diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go index 1e86777..2ab81f5 100644 --- a/internal/core/runner/form/runner.go +++ b/internal/core/runner/form/runner.go @@ -1,11 +1,12 @@ package form import ( - "encoding/json" "fmt" "regexp" "strings" + "github.com/goccy/go-json" + "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" @@ -42,16 +43,18 @@ func NewRunner() *Runner { } } +// RegisterDirective allows registering custom directives +func (r *Runner) RegisterDirective(handler DirectiveHandler) { + if executor, ok := r.processor.(*CompositeProcessor).mapProcessor.directiveHandler.(*defaultDirectiveExecutor); ok { + executor.RegisterDirective(handler) + } +} + // Apply processes a string input with pass mappings and template resolution func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string, pass []*tests.Pass) string { result := input - - // Apply pass mappings first result = r.applyPassMappings(ctx, result, pass) - - // Apply template resolution result = r.applyTemplateResolution(ctx, result) - return result } @@ -61,23 +64,34 @@ func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, return nil } - // Process the body using the main processor + select { + case <-ctx.Done(): + return nil + default: + } + processed := r.processor.Process(ctx, body, pass, nil, []int{}) + select { + case <-ctx.Done(): + return nil + default: + } - // Convert result to map if processedMap, ok := processed.(map[string]any); ok { - // Resolve references in the processed data resolved := r.referenceResolver.Resolve(ctx, processedMap, processedMap, pass, []int{}) + select { + case <-ctx.Done(): + return nil + default: + } - if resolvedMap, ok := resolved.(map[string]any); ok { - // Debug output (can be removed or made configurable) + if resolvedMap, is := resolved.(map[string]any); is { if data, err := json.MarshalIndent(resolvedMap, "", " "); err == nil { fmt.Println(string(data)) } return resolvedMap } } - return body } @@ -86,9 +100,14 @@ func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string] if headers == nil { return nil } - result := make(map[string]string, len(headers)) for key, value := range headers { + select { + case <-ctx.Done(): + return result + default: + } + processedKey := r.processHeaderValue(ctx, key, pass) processedValue := r.processHeaderValue(ctx, value, pass) result[processedKey] = processedValue @@ -96,11 +115,20 @@ func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string] return result } -// Private helper methods +// GetTemplateEngine returns the underlying template engine for advanced usage +func (r *Runner) GetTemplateEngine() *templates.TemplateEngine { + return r.templateEngine +} +// Private helper methods func (r *Runner) applyPassMappings(ctx interfaces.ExecutionContext, input string, pass []*tests.Pass) string { result := input for _, p := range pass { + select { + case <-ctx.Done(): + return result + default: + } if p.Map != nil { for placeholder, mapKey := range p.Map { if strings.Contains(result, placeholder) { @@ -117,38 +145,38 @@ func (r *Runner) applyPassMappings(ctx interfaces.ExecutionContext, input string func (r *Runner) applyTemplateResolution(ctx interfaces.ExecutionContext, input string) string { reg := regexp.MustCompile(`\{\{\s*([^}\s]+)\s*}}`) return reg.ReplaceAllStringFunc(input, func(match string) string { + select { + case <-ctx.Done(): + return match + default: + } 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.templateEngine.Execute(match); err == nil { return fmt.Sprintf("%v", val) } } - return match }) } func (r *Runner) processHeaderValue(ctx interfaces.ExecutionContext, value string, pass []*tests.Pass) string { + select { + case <-ctx.Done(): + return value + default: + } processed := r.processor.Process(ctx, value, pass, nil, []int{}) + select { + case <-ctx.Done(): + return value + default: + } if str, ok := processed.(string); ok { return str } return fmt.Sprintf("%v", processed) } - -// RegisterDirective allows registering custom directives -func (r *Runner) RegisterDirective(handler DirectiveHandler) { - if executor, ok := r.processor.(*CompositeProcessor).mapProcessor.directiveHandler.(*defaultDirectiveExecutor); ok { - executor.RegisterDirective(handler) - } -} - -// GetTemplateEngine returns the underlying template engine for advanced usage -func (r *Runner) GetTemplateEngine() *templates.TemplateEngine { - return r.templateEngine -} diff --git a/internal/core/runner/save/extractor.go b/internal/core/runner/save/extractor.go index fc2b768..8117b11 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -1,10 +1,9 @@ package save import ( - "encoding/json" - "fmt" "net/http" - "strings" + + "github.com/goccy/go-json" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" @@ -24,15 +23,12 @@ func NewExtractor() *Extractor { } func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manifest, c tests.HttpCase, resp *http.Response, reqBody, respBody []byte, caseResult *interfaces.CaseResult) { - key := FormSaveKey(man.GetID(), c.Name, ResultKeySuffix) + key := FormSaveKey(man.GetID(), ResultKeySuffix) result := &Result{ ManifestID: man.GetID(), CaseName: c.Name, - Target: resp.Request.URL.String(), - Method: resp.Request.Method, - StatusCode: resp.StatusCode, - Duration: caseResult.Duration, + ResultCase: caseResult, Request: &Entry{ Headers: make(map[string]string), Body: make(map[string]any), @@ -43,31 +39,34 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif }, } - defer func() { - var builder strings.Builder - - builder.WriteString("\nExtractor:") - builder.WriteString(fmt.Sprintf("\nID: %s\nCase: %s\nTarget: %s\n Status: %d", result.ManifestID, result.CaseName, result.Target, result.StatusCode)) - - reqData, _ := json.MarshalIndent(result.Request, "", " ") - builder.WriteString(fmt.Sprintf("\n\tRequest: %v", string(reqData))) - - resData, _ := json.MarshalIndent(result.Response, "", " ") - builder.WriteString(fmt.Sprintf("\n\tResponse: %v", string(resData))) - - ctx.GetOutput().Logf(interfaces.DebugLevel, builder.String()) + if resp != nil { + result.Target = resp.Request.URL.String() + result.Method = resp.Request.Method + } - ctx.Set(key, result) + defer func() { + if val, ok := ctx.Get(key); !ok { + results := []*Result{result} + ctx.Set(key, results) + } else { + results := val.([]*Result) + results = append(results, result) + ctx.Set(key, results) + } }() if c.Save != nil { if c.Save.Request != nil { - result.Request.Headers = e.extractHeaders(c.Save.Request.Headers, resp.Request.Header, result.Request.Headers) - result.Request.Body = e.extractBody(c.Save.Request.Body, reqBody, result.Response.Body) + if resp != nil { + result.Request.Headers = e.extractHeaders(c.Save.Request.Headers, resp.Request.Header, result.Request.Headers) + } + result.Request.Body = e.extractBody(c.Save.Request.Body, reqBody, result.Request.Body) } if c.Save.Response != nil { - result.Response.Headers = e.extractHeaders(c.Save.Response.Headers, resp.Header, result.Response.Headers) + if resp != nil { + result.Response.Headers = e.extractHeaders(c.Save.Response.Headers, resp.Header, result.Response.Headers) + } result.Response.Body = e.extractBody(c.Save.Response.Body, respBody, result.Response.Body) } } @@ -87,6 +86,10 @@ func (e *Extractor) extractHeaders(list []string, origin http.Header, source map } func (e *Extractor) extractBody(mapList map[string]string, origin []byte, source map[string]any) map[string]any { + if len(origin) == 0 { + return source + } + var value any var once bool diff --git a/internal/core/runner/save/result.go b/internal/core/runner/save/result.go index 6a92f94..3368bfb 100644 --- a/internal/core/runner/save/result.go +++ b/internal/core/runner/save/result.go @@ -2,7 +2,8 @@ package save import ( "fmt" - "time" + + "github.com/apiqube/cli/internal/core/runner/interfaces" ) type Result struct { @@ -10,8 +11,8 @@ type Result struct { CaseName string Target string Method string - Duration time.Duration - StatusCode int + + ResultCase *interfaces.CaseResult Request *Entry Response *Entry @@ -22,6 +23,6 @@ type Entry struct { Body map[string]any } -func FormSaveKey(manifestID, caseName, suffix string) string { - return fmt.Sprintf("%s.%s.%s.%s", KeyPrefix, manifestID, caseName, suffix) +func FormSaveKey(manifestID, suffix string) string { + return fmt.Sprintf("%s.%s.%s", KeyPrefix, manifestID, suffix) } diff --git a/internal/core/runner/templates/engine_test.go b/internal/core/runner/templates/engine_test.go index e07cb66..45d5481 100644 --- a/internal/core/runner/templates/engine_test.go +++ b/internal/core/runner/templates/engine_test.go @@ -26,7 +26,7 @@ func TestTemplateEngine_FakeGenerators(t *testing.T) { {"{{ Fake.uuid }}", `^[a-f0-9-]{36}$`, "Fake.uuid"}, {"{{ Fake.url }}", `^https?://`, "Fake.url"}, {"{{ Fake.color }}", `.+`, "Fake.color"}, - {"{{ Fake.word }}", `^[A-Za-z]+$`, "Fake.word"}, + {"{{ Fake.word }}", `.+`, "Fake.word"}, {"{{ Fake.sentence }}", `.+`, "Fake.sentence"}, {"{{ Fake.country }}", `.+`, "Fake.country"}, {"{{ Fake.city }}", `.+`, "Fake.city"}, diff --git a/internal/core/store/db.go b/internal/core/store/db.go index 931f3d9..c189fec 100644 --- a/internal/core/store/db.go +++ b/internal/core/store/db.go @@ -1,7 +1,6 @@ package store import ( - "encoding/json" "errors" "fmt" "math" @@ -12,6 +11,8 @@ import ( "strings" "time" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/operations" "github.com/apiqube/cli/internal/core/manifests/kinds" diff --git a/internal/operations/edit.go b/internal/operations/edit.go index 06531c3..1a286e0 100644 --- a/internal/operations/edit.go +++ b/internal/operations/edit.go @@ -1,15 +1,16 @@ package operations import ( - "encoding/json" "errors" "fmt" "os" "os/exec" "runtime" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) var ErrFileNotEdited = errors.New("file was not edited") diff --git a/internal/operations/normalize.go b/internal/operations/normalize.go new file mode 100644 index 0000000..35234d4 --- /dev/null +++ b/internal/operations/normalize.go @@ -0,0 +1,87 @@ +package operations + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/goccy/go-json" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/goccy/go-yaml" +) + +func NormalizeJSON(m manifests.Manifest) ([]byte, error) { + data, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to normalize manifest: %v", err) + } + var raw any + if err = json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to normalize manifest: %v", err) + } + sorted := sortAny(raw) + // Compact encoding: no spaces, tabs, or newlines + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", "") // no indent + if err = enc.Encode(sorted); err != nil { + return nil, fmt.Errorf("failed to encode normalized manifest: %v", err) + } + // Remove trailing newline added by Encoder + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + +func NormalizeYAML(m manifests.Manifest) ([]byte, error) { + // Marshal to JSON first for canonicalization + jsonData, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to normalize manifest: %v", err) + } + var raw any + if err = json.Unmarshal(jsonData, &raw); err != nil { + return nil, fmt.Errorf("failed to normalize manifest: %v", err) + } + sorted := sortAny(raw) + // Marshal to YAML + data, err := yaml.Marshal(sorted) + if err != nil { + return nil, fmt.Errorf("failed to encode normalized manifest: %v", err) + } + // Remove trailing spaces and extra newlines + lines := strings.Split(string(data), "\n") + var compactLines []string + for _, line := range lines { + l := strings.TrimRight(line, " \t") + if l != "" { + compactLines = append(compactLines, l) + } + } + return []byte(strings.Join(compactLines, "\n")), nil +} + +// Recursively sort all map keys and arrays of maps for canonical output +func sortAny(v any) any { + switch val := v.(type) { + case map[string]any: + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + res := make(map[string]any, len(val)) + for _, k := range keys { + res[k] = sortAny(val[k]) + } + return res + case []any: + for i := range val { + val[i] = sortAny(val[i]) + } + return val + default: + return val + } +} diff --git a/internal/operations/normalize_test.go b/internal/operations/normalize_test.go new file mode 100644 index 0000000..030d87f --- /dev/null +++ b/internal/operations/normalize_test.go @@ -0,0 +1,151 @@ +package operations + +import ( + "sync" + "testing" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests/kinds/servers" + "github.com/apiqube/cli/internal/core/manifests/kinds/services" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/load" + "github.com/apiqube/cli/internal/core/manifests/kinds/values" + "github.com/apiqube/cli/internal/core/manifests/utils" +) + +func hash(data []byte) string { + h, _ := utils.CalculateContentHash(data) + return h +} + +func TestNormalize_StableHashes(t *testing.T) { + testCases := []struct { + name string + manifest manifests.Manifest + }{ + { + name: "Plan (realistic)", + manifest: &plan.Plan{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.PlanKind, + Metadata: kinds.Metadata{ + Name: "plan", + }, + }, + }, + }, + { + name: "Values", + manifest: &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values", + }, + }, + }, + }, + { + name: "Server", + manifest: &servers.Server{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServerKind, + Metadata: kinds.Metadata{ + Name: "server", + }, + }, + }, + }, + { + name: "Service", + manifest: &services.Service{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServiceKind, + Metadata: kinds.Metadata{ + Name: "service", + }, + }, + }, + }, + { + name: "HttpTest", + manifest: &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test", + }, + }, + }, + }, + { + name: "HttpLoadTest", + manifest: &load.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpLoadTestKind, + Metadata: kinds.Metadata{ + Name: "http-load-test", + }, + }, + }, + }, + } + + const runs = 3 + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + hashesYAML := make([]string, 0, runs) + hashesJSON := make([]string, 0, runs) + var wg sync.WaitGroup + var mu sync.Mutex + + for i := 0; i < runs; i++ { + wg.Add(2) + go func() { + defer wg.Done() + data, err := NormalizeYAML(tc.manifest) + if err != nil { + t.Errorf("NormalizeYAML failed: %v", err) + return + } + mu.Lock() + hashesYAML = append(hashesYAML, hash(data)) + mu.Unlock() + }() + go func() { + defer wg.Done() + data, err := NormalizeJSON(tc.manifest) + if err != nil { + t.Errorf("NormalizeJSON failed: %v", err) + return + } + mu.Lock() + hashesJSON = append(hashesJSON, hash(data)) + mu.Unlock() + }() + } + wg.Wait() + for i := 1; i < len(hashesYAML); i++ { + if hashesYAML[i] != hashesYAML[0] { + t.Errorf("YAML hashes not equal for manifest %s: %v", tc.name, hashesYAML) + } + } + + for i := 1; i < len(hashesJSON); i++ { + if hashesJSON[i] != hashesJSON[0] { + t.Errorf("JSON hashes not equal for manifest %s: %v", tc.name, hashesJSON) + } + } + }) + } +} diff --git a/internal/operations/normileze.go b/internal/operations/normileze.go deleted file mode 100644 index 70b7428..0000000 --- a/internal/operations/normileze.go +++ /dev/null @@ -1,62 +0,0 @@ -package operations - -import ( - "encoding/json" - "fmt" - "sort" - - "github.com/apiqube/cli/internal/core/manifests" - "gopkg.in/yaml.v3" -) - -func NormalizeYAML(m manifests.Manifest) ([]byte, error) { - data, err := yaml.Marshal(m) - if err != nil { - return nil, fmt.Errorf("failed normilize manifest: %v", err) - } - - var raw map[string]interface{} - if err = yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed normilize manifest: %v", err) - } - - sorted := sortMapKeys(raw) - - return yaml.Marshal(sorted) -} - -func NormalizeJSON(m manifests.Manifest) ([]byte, error) { - data, err := json.Marshal(m) - if err != nil { - return nil, fmt.Errorf("failed normilize manifest: %v", err) - } - - var raw map[string]interface{} - if err = json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed normilize manifest: %v", err) - } - - sorted := sortMapKeys(raw) - - return json.Marshal(sorted) -} - -func sortMapKeys(m map[string]interface{}) map[string]interface{} { - res := make(map[string]interface{}) - keys := make([]string, 0, len(m)) - - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - if nested, ok := m[k].(map[string]interface{}); ok { - res[k] = sortMapKeys(nested) - } else { - res[k] = m[k] - } - } - - return res -} diff --git a/internal/operations/parse.go b/internal/operations/parse.go index 756caa9..d2680ff 100644 --- a/internal/operations/parse.go +++ b/internal/operations/parse.go @@ -2,10 +2,11 @@ package operations import ( "bytes" - "encoding/json" "errors" "fmt" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/kinds/values" @@ -16,7 +17,7 @@ import ( "github.com/apiqube/cli/internal/core/manifests/kinds/servers" "github.com/apiqube/cli/internal/core/manifests/kinds/services" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/load" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) type rawManifest struct { diff --git a/internal/operations/parse_test.go b/internal/operations/parse_test.go new file mode 100644 index 0000000..cf25c61 --- /dev/null +++ b/internal/operations/parse_test.go @@ -0,0 +1,143 @@ +package operations + +import ( + "testing" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/goccy/go-yaml" + + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests/kinds/servers" + "github.com/apiqube/cli/internal/core/manifests/kinds/services" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/load" + "github.com/apiqube/cli/internal/core/manifests/kinds/values" +) + +func TestParse_Manifests(t *testing.T) { + testCases := []struct { + name string + manifest manifests.Manifest + expectedType string + expectErr bool + customData []byte // for error/empty cases + }{ + { + name: "Plan (realistic)", + manifest: &plan.Plan{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.PlanKind, + Metadata: kinds.Metadata{ + Name: "plan", + }, + }, + }, + expectedType: manifests.PlanKind, + }, + { + name: "Values", + manifest: &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values", + }, + }, + }, + expectedType: manifests.ValuesKind, + }, + { + name: "Server", + manifest: &servers.Server{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServerKind, + Metadata: kinds.Metadata{ + Name: "server", + }, + }, + }, + expectedType: manifests.ServerKind, + }, + { + name: "Service", + manifest: &services.Service{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServiceKind, + Metadata: kinds.Metadata{ + Name: "service", + }, + }, + }, + expectedType: manifests.ServiceKind, + }, + { + name: "HttpTest", + manifest: &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test", + }, + }, + }, + expectedType: manifests.HttpTestKind, + }, + { + name: "HttpLoadTest", + manifest: &load.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpLoadTestKind, + Metadata: kinds.Metadata{ + Name: "http-load-test", + }, + }, + }, + expectedType: manifests.HttpLoadTestKind, + }, + { + name: "UnknownKind", + expectErr: true, + customData: []byte("kind: UnknownKind\napiVersion: v1\nmetadata:\n name: test\n"), + }, + { + name: "EmptyData", + expectErr: true, + customData: []byte(""), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var data []byte + var err error + if tc.manifest != nil { + data, err = yaml.Marshal(tc.manifest) + if err != nil { + t.Fatalf("Failed to marshal manifest: %v", err) + } + } else { + data = tc.customData + } + m, err := Parse(YAMLFormat, data) + if tc.expectErr { + if err == nil { + t.Fatalf("Expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if m.GetKind() != tc.expectedType { + t.Fatalf("Expected kind %s, got %s", tc.expectedType, m.GetKind()) + } + }) + } +} diff --git a/internal/report/html.go b/internal/report/html.go new file mode 100644 index 0000000..16b59c4 --- /dev/null +++ b/internal/report/html.go @@ -0,0 +1,157 @@ +package report + +import ( + "embed" + "fmt" + "html/template" + "os" + "path/filepath" + "time" + + "github.com/apiqube/cli/internal/core/runner/save" +) + +//go:embed html/templates/*.gohtml +var htmlTemplates embed.FS + +// CaseReport is a view model for a single test case. +type CaseReport struct { + Name string + Success bool + Assert string + StatusCode int + Duration time.Duration + Errors []string + Method string + Request *save.Entry + Response *save.Entry +} + +// ManifestReport groups results for a single manifest. +type ManifestReport struct { + ManifestID string + Target string + TotalCases int + PassedCases int + FailedCases int + TotalTime time.Duration + Cases []*CaseReport +} + +// ViewData is the data passed to the HTML template. +type ViewData struct { + GeneratedAt time.Time + TotalCases int + PassedCases int + FailedCases int + TotalTime time.Duration + ManifestStats []*ManifestReport +} + +// BuildReportViewData aggregates results and statistics for the template. +func BuildReportViewData(results []*save.Result) *ViewData { + manifestMap := make(map[string]*ManifestReport) + totalCases := 0 + passedCases := 0 + failedCases := 0 + totalTime := time.Duration(0) + + for _, res := range results { + m, ok := manifestMap[res.ManifestID] + if !ok { + m = &ManifestReport{ + ManifestID: res.ManifestID, + Target: res.Target, + Cases: []*CaseReport{}, + } + manifestMap[res.ManifestID] = m + } + cr := res.ResultCase + caseReport := &CaseReport{ + Name: cr.Name, + Success: cr.Success, + Assert: cr.Assert, + StatusCode: cr.StatusCode, + Duration: cr.Duration, + Errors: cr.Errors, + Method: res.Method, + Request: res.Request, + Response: res.Response, + } + m.Cases = append(m.Cases, caseReport) + m.TotalCases++ + m.TotalTime += cr.Duration + if cr.Success { + m.PassedCases++ + passedCases++ + } else { + m.FailedCases++ + failedCases++ + } + totalCases++ + totalTime += cr.Duration + } + manifestStats := make([]*ManifestReport, 0, len(manifestMap)) + for _, m := range manifestMap { + manifestStats = append(manifestStats, m) + } + return &ViewData{ + GeneratedAt: time.Now(), + TotalCases: totalCases, + PassedCases: passedCases, + FailedCases: failedCases, + TotalTime: totalTime, + ManifestStats: manifestStats, + } +} + +// HTMLReportGenerator generates an HTML report using html/template and gohtml templates. +type HTMLReportGenerator struct { + tmpl *template.Template + funcs template.FuncMap +} + +// NewHTMLReportGenerator creates a new HTMLReportGenerator with custom functions and template directory. +func NewHTMLReportGenerator() (*HTMLReportGenerator, error) { + funcs := template.FuncMap{ + "formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, + "statusText": func(success bool) string { + if success { + return "PASSED" + } + return "FAILED" + }, + // Add more custom functions here as needed + } + + tmpl, err := template.New("base.gohtml").Funcs(funcs).ParseFS(htmlTemplates, "html/templates/*.gohtml") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + return &HTMLReportGenerator{ + tmpl: tmpl, + funcs: funcs, + }, nil +} + +// Generate creates an HTML report from test results and writes it to outputPath. +func (g *HTMLReportGenerator) Generate(results []*save.Result) error { + data := BuildReportViewData(results) + + outputPath := filepath.Join("C:\\Users\\admin\\Desktop\\reports", fmt.Sprintf("/report_%s.html", time.Now().Format("2006-01-02-150405"))) + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create report file: %w", err) + } + + defer func() { + _ = file.Close() + }() + + if err = g.tmpl.Execute(file, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} diff --git a/internal/report/html/templates/base.gohtml b/internal/report/html/templates/base.gohtml new file mode 100644 index 0000000..845476e --- /dev/null +++ b/internal/report/html/templates/base.gohtml @@ -0,0 +1,37 @@ +{{/* + Base template for the test report +*/}} + + + + + + Test Report + + + + +
+

Test Report

+
Generated at: {{ formatTime .GeneratedAt }}
+
+
+

Summary

+
    +
  • Total Cases: {{ .TotalCases }}
  • +
  • Passed: {{ .PassedCases }}
  • +
  • Failed: {{ .FailedCases }}
  • +
  • Total Time: {{ .TotalTime }}
  • +
+
+
+
+ {{ range .ManifestStats }} + {{ template "manifest.gohtml" . }} + {{ end }} +
+
+ + diff --git a/internal/report/html/templates/case.gohtml b/internal/report/html/templates/case.gohtml new file mode 100644 index 0000000..d6d0a37 --- /dev/null +++ b/internal/report/html/templates/case.gohtml @@ -0,0 +1,23 @@ +{{/* + Case component for the test report +*/}} +
  • +
    +
    + {{ .Name }} + + {{ statusText .Success }} + + Method: {{ .Method }} + Status: {{ .StatusCode }} + Time: {{ .Duration }} +
    +
    + {{ if .Errors }} + + {{ end }} +
  • diff --git a/internal/report/html/templates/manifest.gohtml b/internal/report/html/templates/manifest.gohtml new file mode 100644 index 0000000..22edef6 --- /dev/null +++ b/internal/report/html/templates/manifest.gohtml @@ -0,0 +1,18 @@ +{{/* + Manifest component for the test report +*/}} +
    +

    Manifest: {{ .ManifestID }}

    +
    Target: {{ .Target }}
    +
    + Total: {{ .TotalCases }} + Passed: {{ .PassedCases }} + Failed: {{ .FailedCases }} + Time: {{ .TotalTime }} +
    + +
    diff --git a/internal/report/interfaces.go b/internal/report/interfaces.go new file mode 100644 index 0000000..5e4a8af --- /dev/null +++ b/internal/report/interfaces.go @@ -0,0 +1,7 @@ +package report + +import "github.com/apiqube/cli/internal/core/runner/save" + +type Generator interface { + Generate(results []*save.Result) error +} diff --git a/internal/report/service.go b/internal/report/service.go new file mode 100644 index 0000000..bbd0261 --- /dev/null +++ b/internal/report/service.go @@ -0,0 +1,37 @@ +package report + +import ( + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/apiqube/cli/internal/core/runner/save" +) + +// Service aggregates results from ExecutionContext and generates reports. +type Service struct { + generator Generator +} + +// NewReportService creates a new ReportService with the given generator. +func NewReportService(generator Generator) *Service { + return &Service{generator: generator} +} + +// CollectResults collects all save.Result from the context by manifest IDs. +func (s *Service) CollectResults(ctx interfaces.ExecutionContext) []*save.Result { + mans := ctx.GetAllManifests() + results := make([]*save.Result, 0, len(mans)) + + for _, man := range mans { + key := save.FormSaveKey(man.GetID(), save.ResultKeySuffix) + if val, ok := ctx.Get(key); ok { + if res, is := val.([]*save.Result); is { + results = append(results, res...) + } + } + } + + return results +} + +func (s *Service) GenerateReports(ctx interfaces.ExecutionContext) error { + return s.generator.Generate(s.CollectResults(ctx)) +}