From 61b077259e7f86e18df23302c69ed93b3d021e84 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 09:35:28 +0200 Subject: [PATCH 01/19] chore(core): small adjustment in gitignore (added config for qudo), added tests for operation parse function --- .gitignore | 1 + .../http_test.yaml | 4 +- go.mod | 1 + go.sum | 13 ++ internal/operations/parse_test.go | 142 ++++++++++++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) rename examples/{simple-http-tests-1 => complex-http-tests}/http_test.yaml (89%) create mode 100644 internal/operations/parse_test.go diff --git a/.gitignore b/.gitignore index 95ceb91..c91c47a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.so *.dylib bin +.qodo # Test binary, built with `go test -c` *.test diff --git a/examples/simple-http-tests-1/http_test.yaml b/examples/complex-http-tests/http_test.yaml similarity index 89% rename from examples/simple-http-tests-1/http_test.yaml rename to examples/complex-http-tests/http_test.yaml index 7247d12..62bb625 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 diff --git a/go.mod b/go.mod index 7c50bba..5052d09 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ 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/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 2468d11..58a3a45 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ 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/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 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= @@ -214,6 +216,7 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= @@ -230,28 +233,35 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -277,10 +287,13 @@ golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/internal/operations/parse_test.go b/internal/operations/parse_test.go new file mode 100644 index 0000000..bdfd9ca --- /dev/null +++ b/internal/operations/parse_test.go @@ -0,0 +1,142 @@ +package operations + +import ( + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "gopkg.in/yaml.v3" + "testing" + + "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()) + } + }) + } +} From d292f1e87ea9c2cb8e4085f1ceea8b7a177f7fdc Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 10:05:11 +0200 Subject: [PATCH 02/19] chore(core): added tests for operation normalize functionality --- internal/operations/normalize.go | 86 +++++++++++++++++ internal/operations/normalize_test.go | 134 ++++++++++++++++++++++++++ internal/operations/normileze.go | 62 ------------ 3 files changed, 220 insertions(+), 62 deletions(-) create mode 100644 internal/operations/normalize.go create mode 100644 internal/operations/normalize_test.go delete mode 100644 internal/operations/normileze.go diff --git a/internal/operations/normalize.go b/internal/operations/normalize.go new file mode 100644 index 0000000..155372a --- /dev/null +++ b/internal/operations/normalize.go @@ -0,0 +1,86 @@ +package operations + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "gopkg.in/yaml.v3" +) + +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 interface{} + 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 interface{} + 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..604863f --- /dev/null +++ b/internal/operations/normalize_test.go @@ -0,0 +1,134 @@ +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() + hashes := make([]string, 0, runs*2) + var wg sync.WaitGroup + var mu sync.Mutex + + for i := 0; i < runs; i++ { + wg.Add(1) + go func() { + defer wg.Done() + data, err := NormalizeYAML(tc.manifest) + if err != nil { + t.Errorf("NormalizeYAML failed: %v", err) + return + } + h := hash(data) + mu.Lock() + hashes = append(hashes, h) + mu.Unlock() + }() + } + wg.Wait() + for i := 1; i < len(hashes); i++ { + if hashes[i] != hashes[0] { + t.Errorf("Hashes not equal for manifest %s: %v", tc.name, hashes) + } + } + }) + } +} 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 -} From 71b99a8589028a797fa8a8fbf7e50c25b3306918 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 10:13:59 +0200 Subject: [PATCH 03/19] chore(core): moved from standard yaml package to goccy/go-yaml package, added json normalize tests --- cmd/cli/apply/apply.go | 4 ++-- cmd/cli/generator/generate.go | 2 +- go.mod | 2 +- go.sum | 15 ++----------- internal/core/io/write.go | 2 +- internal/operations/edit.go | 2 +- internal/operations/normalize.go | 2 +- internal/operations/normalize_test.go | 31 +++++++++++++++++++++------ internal/operations/parse.go | 2 +- internal/operations/parse_test.go | 2 +- 10 files changed, 35 insertions(+), 29 deletions(-) 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/go.mod b/go.mod index 5052d09..81c037f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ 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/golang/mock v1.6.0 + 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 diff --git a/go.sum b/go.sum index 58a3a45..562a626 100644 --- a/go.sum +++ b/go.sum @@ -110,8 +110,8 @@ 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/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +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= @@ -216,7 +216,6 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= @@ -233,35 +232,28 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -287,13 +279,10 @@ golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/internal/core/io/write.go b/internal/core/io/write.go index 5de9278..59d2346 100644 --- a/internal/core/io/write.go +++ b/internal/core/io/write.go @@ -8,7 +8,7 @@ import ( "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/operations/edit.go b/internal/operations/edit.go index 06531c3..be57760 100644 --- a/internal/operations/edit.go +++ b/internal/operations/edit.go @@ -9,7 +9,7 @@ import ( "runtime" "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 index 155372a..3ea3ab4 100644 --- a/internal/operations/normalize.go +++ b/internal/operations/normalize.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/apiqube/cli/internal/core/manifests" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) func NormalizeJSON(m manifests.Manifest) ([]byte, error) { diff --git a/internal/operations/normalize_test.go b/internal/operations/normalize_test.go index 604863f..f1c0f93 100644 --- a/internal/operations/normalize_test.go +++ b/internal/operations/normalize_test.go @@ -104,12 +104,13 @@ func TestNormalize_StableHashes(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - hashes := make([]string, 0, runs*2) + 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(1) + wg.Add(2) go func() { defer wg.Done() data, err := NormalizeYAML(tc.manifest) @@ -117,16 +118,32 @@ func TestNormalize_StableHashes(t *testing.T) { t.Errorf("NormalizeYAML failed: %v", err) return } - h := hash(data) mu.Lock() - hashes = append(hashes, h) + 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(hashesYAML, hash(data)) mu.Unlock() }() } wg.Wait() - for i := 1; i < len(hashes); i++ { - if hashes[i] != hashes[0] { - t.Errorf("Hashes not equal for manifest %s: %v", tc.name, hashes) + 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/parse.go b/internal/operations/parse.go index 756caa9..fb7ff4a 100644 --- a/internal/operations/parse.go +++ b/internal/operations/parse.go @@ -16,7 +16,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 index bdfd9ca..ffbde59 100644 --- a/internal/operations/parse_test.go +++ b/internal/operations/parse_test.go @@ -3,7 +3,7 @@ package operations import ( "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" "testing" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" From 464b449e8a7ccd94fae181ac6a52aa07467b47e7 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 10:28:25 +0200 Subject: [PATCH 04/19] chore(core): moved from standard json package to goccy/go-json package, tided --- go.mod | 3 ++- go.sum | 2 ++ internal/core/io/write.go | 3 ++- internal/core/runner/assert/runner.go | 3 ++- internal/core/runner/executor/executors/http.go | 3 ++- internal/core/runner/form/runner.go | 3 ++- internal/core/runner/save/extractor.go | 3 ++- internal/core/runner/templates/engine_test.go | 2 +- internal/core/store/db.go | 3 ++- internal/operations/edit.go | 3 ++- internal/operations/normalize.go | 7 ++++--- internal/operations/normalize_test.go | 2 +- internal/operations/parse.go | 3 ++- internal/operations/parse_test.go | 3 ++- 14 files changed, 28 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 81c037f..e3095c9 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ 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 @@ -17,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 ( @@ -99,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 562a626..6381078 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ 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= diff --git a/internal/core/io/write.go b/internal/core/io/write.go index 59d2346..6ad1f50 100644 --- a/internal/core/io/write.go +++ b/internal/core/io/write.go @@ -1,11 +1,12 @@ 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" "github.com/goccy/go-yaml" diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 812e7c2..7c45b6d 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -2,13 +2,14 @@ package assert import ( "bytes" - "encoding/json" "errors" "fmt" "net/http" "reflect" "strings" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/runner/templates" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index e0d6239..df785fd 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -3,7 +3,6 @@ package executors import ( "bytes" "context" - "encoding/json" "errors" "fmt" "io" @@ -12,6 +11,8 @@ import ( "sync" "time" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/runner/metrics" "github.com/apiqube/cli/internal/core/manifests" diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go index 1e86777..18236d8 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" diff --git a/internal/core/runner/save/extractor.go b/internal/core/runner/save/extractor.go index fc2b768..8418b9f 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -1,11 +1,12 @@ 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" "github.com/apiqube/cli/internal/core/runner/interfaces" 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 be57760..1a286e0 100644 --- a/internal/operations/edit.go +++ b/internal/operations/edit.go @@ -1,13 +1,14 @@ package operations import ( - "encoding/json" "errors" "fmt" "os" "os/exec" "runtime" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests" "github.com/goccy/go-yaml" ) diff --git a/internal/operations/normalize.go b/internal/operations/normalize.go index 3ea3ab4..35234d4 100644 --- a/internal/operations/normalize.go +++ b/internal/operations/normalize.go @@ -2,11 +2,12 @@ package operations import ( "bytes" - "encoding/json" "fmt" "sort" "strings" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests" "github.com/goccy/go-yaml" ) @@ -16,7 +17,7 @@ func NormalizeJSON(m manifests.Manifest) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to normalize manifest: %v", err) } - var raw interface{} + var raw any if err = json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("failed to normalize manifest: %v", err) } @@ -39,7 +40,7 @@ func NormalizeYAML(m manifests.Manifest) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to normalize manifest: %v", err) } - var raw interface{} + var raw any if err = json.Unmarshal(jsonData, &raw); err != nil { return nil, fmt.Errorf("failed to normalize manifest: %v", err) } diff --git a/internal/operations/normalize_test.go b/internal/operations/normalize_test.go index f1c0f93..030d87f 100644 --- a/internal/operations/normalize_test.go +++ b/internal/operations/normalize_test.go @@ -130,7 +130,7 @@ func TestNormalize_StableHashes(t *testing.T) { return } mu.Lock() - hashesJSON = append(hashesYAML, hash(data)) + hashesJSON = append(hashesJSON, hash(data)) mu.Unlock() }() } diff --git a/internal/operations/parse.go b/internal/operations/parse.go index fb7ff4a..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" diff --git a/internal/operations/parse_test.go b/internal/operations/parse_test.go index ffbde59..cf25c61 100644 --- a/internal/operations/parse_test.go +++ b/internal/operations/parse_test.go @@ -1,10 +1,11 @@ package operations import ( + "testing" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/goccy/go-yaml" - "testing" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/kinds/servers" From b929ae152f146d2a12975383f62cfb4a8d32167d Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 11:13:17 +0200 Subject: [PATCH 05/19] chore(executor): executor and other scripts a little refactored --- internal/core/runner/assert/runner.go | 13 ++- .../core/runner/executor/executors/http.go | 81 +++++++------------ internal/core/runner/save/extractor.go | 24 ++++-- internal/core/runner/save/result.go | 7 +- 4 files changed, 62 insertions(+), 63 deletions(-) diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 7c45b6d..6bb7241 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -59,8 +59,17 @@ func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { if assert.Equals != nil { - expectedCode, ok := assert.Equals.(int) - if !ok { + var expectedCode int + switch val := assert.Equals.(type) { + case uint: + expectedCode = int(val) + case uint64: + expectedCode = int(val) + case int64: + expectedCode = int(val) + case int: + expectedCode = val + default: return fmt.Errorf("expected status type [int] got %T", assert.Equals) } diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index df785fd..a14d91e 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "net/http" "strings" "sync" @@ -13,19 +12,19 @@ import ( "github.com/goccy/go-json" - "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/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) @@ -46,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) @@ -61,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 } @@ -106,29 +99,25 @@ 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 ) output.StartCase(man, c.Name) - defer func() { metrics.CollectHTTPMetrics(req, resp, c.Details, caseResult) - + e.extractor.Extract(ctx, man, c.HttpCase, resp, reqBody.Bytes(), 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())) @@ -146,57 +135,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 } @@ -204,7 +185,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 != "" { @@ -213,6 +193,5 @@ func buildHttpURL(url, target, endpoint string) string { url = ep } } - return url } diff --git a/internal/core/runner/save/extractor.go b/internal/core/runner/save/extractor.go index 8418b9f..4f5e2ea 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -30,10 +30,7 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif 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), @@ -44,11 +41,16 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif }, } + if resp != nil { + result.Target = resp.Request.URL.String() + result.Method = resp.Request.Method + } + 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)) + builder.WriteString(fmt.Sprintf("\nID: %s\nCase: %s\nTarget: %s\n Status: %d", result.ManifestID, result.CaseName, result.Target, result.ResultCase.StatusCode)) reqData, _ := json.MarshalIndent(result.Request, "", " ") builder.WriteString(fmt.Sprintf("\n\tRequest: %v", string(reqData))) @@ -63,12 +65,16 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif if c.Save != nil { if c.Save.Request != nil { - result.Request.Headers = e.extractHeaders(c.Save.Request.Headers, resp.Request.Header, result.Request.Headers) + 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.Response.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) } } @@ -88,6 +94,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..2b05f6d 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 From bab5cb6dadc13bb3e35f98e8d10c85276d484abc Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 11:17:20 +0200 Subject: [PATCH 06/19] chore(runner): assert package refactored, tided and styled --- internal/core/runner/assert/runner.go | 138 +++++++++++++------------- 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 6bb7241..cc21916 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -10,10 +10,9 @@ import ( "github.com/goccy/go-json" - "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" + "github.com/apiqube/cli/internal/core/runner/templates" ) type Type string @@ -38,124 +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 { - var expectedCode int - switch val := assert.Equals.(type) { - case uint: - expectedCode = int(val) - case uint64: - expectedCode = int(val) - case int64: - expectedCode = int(val) - case int: - expectedCode = val - default: - 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 } @@ -164,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) + } +} From bf460de3779cacf49c7baebf68d4e758fdf5cd4f Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 10 Jun 2025 12:03:36 +0200 Subject: [PATCH 07/19] chore(runner): some refactors in http executor, fixed issue with request body saving to result case, added colored coverage heatmap report generation --- .gitignore | 1 + Taskfile.yml | 5 ++ examples/complex-http-tests/http_test.yaml | 9 +- .../core/runner/executor/executors/http.go | 5 +- internal/core/runner/form/runner.go | 83 ++++++++++++------- internal/core/runner/save/extractor.go | 2 +- 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index c91c47a..490633f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.dylib bin .qodo +cover.svg # Test binary, built with `go test -c` *.test 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/examples/complex-http-tests/http_test.yaml b/examples/complex-http-tests/http_test.yaml index 62bb625..58bebcc 100644 --- a/examples/complex-http-tests/http_test.yaml +++ b/examples/complex-http-tests/http_test.yaml @@ -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/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index a14d91e..70de753 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -105,11 +105,13 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c 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, reqBody.Bytes(), respBody.Bytes(), caseResult) + e.extractor.Extract(ctx, man, c.HttpCase, resp, reqBodyCopy, respBody.Bytes(), caseResult) + output.EndCase(man, c.Name, caseResult) }() @@ -125,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())) diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go index 18236d8..2ab81f5 100644 --- a/internal/core/runner/form/runner.go +++ b/internal/core/runner/form/runner.go @@ -43,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 } @@ -62,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 } @@ -87,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 @@ -97,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) { @@ -118,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 4f5e2ea..cf74ace 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -68,7 +68,7 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif 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.Response.Body) + result.Request.Body = e.extractBody(c.Save.Request.Body, reqBody, result.Request.Body) } if c.Save.Response != nil { From d8f7a9a7672a6d8bb813dc33ce5ec07e4f208347 Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 13 Jun 2025 00:15:21 +0200 Subject: [PATCH 08/19] feat(runner): added HTML report generation after plan execution, refactored save extractor to collect multiple results per manifest --- .goreleaser.yml | 8 +- internal/core/runner/executor/plan.go | 15 ++ internal/core/runner/save/extractor.go | 29 ++-- internal/core/runner/save/result.go | 4 +- internal/report/html.go | 157 ++++++++++++++++++ internal/report/html/templates/base.gohtml | 37 +++++ internal/report/html/templates/case.gohtml | 23 +++ .../report/html/templates/manifest.gohtml | 18 ++ internal/report/interfaces.go | 7 + internal/report/service.go | 37 +++++ 10 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 internal/report/html.go create mode 100644 internal/report/html/templates/base.gohtml create mode 100644 internal/report/html/templates/case.gohtml create mode 100644 internal/report/html/templates/manifest.gohtml create mode 100644 internal/report/interfaces.go create mode 100644 internal/report/service.go 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/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 37b105c..db4321c 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -3,6 +3,7 @@ package executor import ( "errors" "fmt" + "github.com/apiqube/cli/internal/report" "sync" "github.com/apiqube/cli/internal/core/manifests" @@ -131,6 +132,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/save/extractor.go b/internal/core/runner/save/extractor.go index cf74ace..387ab6f 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -1,11 +1,8 @@ package save import ( - "fmt" - "net/http" - "strings" - "github.com/goccy/go-json" + "net/http" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" @@ -25,7 +22,7 @@ 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(), @@ -47,20 +44,14 @@ 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.ResultCase.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()) - - ctx.Set(key, result) + if val, ok := ctx.Get(key); !ok { + var results = []*Result{result} + ctx.Set(key, results) + } else { + results := val.([]*Result) + results = append(results, result) + ctx.Set(key, results) + } }() if c.Save != nil { diff --git a/internal/core/runner/save/result.go b/internal/core/runner/save/result.go index 2b05f6d..3368bfb 100644 --- a/internal/core/runner/save/result.go +++ b/internal/core/runner/save/result.go @@ -23,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/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 }} +
      + {{ range .Errors }} +
    • {{ . }}
    • + {{ end }} +
    + {{ 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 }} +
    +
      + {{ range .Cases }} + {{ template "case.gohtml" . }} + {{ end }} +
    +
    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)) +} From 0e128ba692f927013160acc9909a19954bcb825c Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 13 Jun 2025 00:17:48 +0200 Subject: [PATCH 09/19] chore(core): moved from standard json package to goccy/go-json package, tided --- internal/core/runner/executor/plan.go | 3 ++- internal/core/runner/save/extractor.go | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index db4321c..7d049c0 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -3,9 +3,10 @@ package executor import ( "errors" "fmt" - "github.com/apiqube/cli/internal/report" "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" diff --git a/internal/core/runner/save/extractor.go b/internal/core/runner/save/extractor.go index 387ab6f..8117b11 100644 --- a/internal/core/runner/save/extractor.go +++ b/internal/core/runner/save/extractor.go @@ -1,9 +1,10 @@ package save import ( - "github.com/goccy/go-json" "net/http" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" @@ -45,7 +46,7 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manif defer func() { if val, ok := ctx.Get(key); !ok { - var results = []*Result{result} + results := []*Result{result} ctx.Set(key, results) } else { results := val.([]*Result) From 65a9b41f39f58637900cf317e433fb3f2f77c85d Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 13 Jun 2025 00:33:34 +0200 Subject: [PATCH 10/19] chore(cli): added semantic release configuration file --- .github/FUNDING/FUNDING.yml | 1 - .semrelrc.yml | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) delete mode 100644 .github/FUNDING/FUNDING.yml create mode 100644 .semrelrc.yml diff --git a/.github/FUNDING/FUNDING.yml b/.github/FUNDING/FUNDING.yml deleted file mode 100644 index d8707bc..0000000 --- a/.github/FUNDING/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -patreon: David Movas diff --git a/.semrelrc.yml b/.semrelrc.yml new file mode 100644 index 0000000..8594351 --- /dev/null +++ b/.semrelrc.yml @@ -0,0 +1,8 @@ +{ + "plugins": { + "version": { + "override": "0.1.0", + "strategy": "minor" + } + } +} \ No newline at end of file From 6a309289d80f30de081ed0732095643cfc5a53fb Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 13 Jun 2025 16:55:38 +0200 Subject: [PATCH 11/19] refactor(report): restructured HTML report generation with improved templates and interface changes --- internal/core/runner/executor/plan.go | 3 +- internal/core/runner/save/result.go | 6 +- internal/report/html.go | 157 ------------- internal/report/html/html.go | 221 ++++++++++++++++++ internal/report/html/templates/base.gohtml | 18 +- internal/report/html/templates/case.gohtml | 99 ++++++-- .../report/html/templates/manifest.gohtml | 44 +++- internal/report/interfaces.go | 6 +- internal/report/service.go | 2 +- 9 files changed, 359 insertions(+), 197 deletions(-) delete mode 100644 internal/report/html.go create mode 100644 internal/report/html/html.go diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 7d049c0..2c1f098 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -3,6 +3,7 @@ package executor import ( "errors" "fmt" + "github.com/apiqube/cli/internal/report/html" "sync" "github.com/apiqube/cli/internal/report" @@ -134,7 +135,7 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma } // TODO: TEMPL CODE HERE !!! - htmlReportGenerator, err := report.NewHTMLReportGenerator() + htmlReportGenerator, err := html.NewHTMLReportGenerator() if err != nil { fmt.Println("ERROR:", err) return nil diff --git a/internal/core/runner/save/result.go b/internal/core/runner/save/result.go index 3368bfb..bb94fc5 100644 --- a/internal/core/runner/save/result.go +++ b/internal/core/runner/save/result.go @@ -11,11 +11,9 @@ type Result struct { CaseName string Target string Method string - ResultCase *interfaces.CaseResult - - Request *Entry - Response *Entry + Request *Entry + Response *Entry } type Entry struct { diff --git a/internal/report/html.go b/internal/report/html.go deleted file mode 100644 index 16b59c4..0000000 --- a/internal/report/html.go +++ /dev/null @@ -1,157 +0,0 @@ -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/html.go b/internal/report/html/html.go new file mode 100644 index 0000000..2032aa7 --- /dev/null +++ b/internal/report/html/html.go @@ -0,0 +1,221 @@ +package html + +import ( + "embed" + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/goccy/go-json" + "html/template" + "os" + "path/filepath" + "time" + + "github.com/apiqube/cli/internal/core/runner/save" +) + +//go:embed templates/*.gohtml +var htmlTemplates embed.FS + +// CaseReport is a view model for a single test case. +type CaseReport struct { + Name string + Method string + Success bool + Assert string + StatusCode int + Duration time.Duration + Errors []string + Details map[string]any + Values map[string]any + Request *save.Entry + Response *save.Entry +} + +// ManifestReport groups results for a single manifest. +type ManifestReport struct { + ManifestID string + Namespace string + Kind string + Name 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(ctx interfaces.ExecutionContext) *ViewData { + mans := ctx.GetAllManifests() + results := make([]*save.Result, 0, len(mans)) + mansMap := make(map[string]manifests.Manifest) + + 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...) + } + } + mansMap[man.GetID()] = man + } + + if len(results) < 1 { + return nil + } + + var reportMap = make(map[string]*ManifestReport) + var totalCases, passedCases, failedCases int + var totalTime time.Duration + + for _, res := range results { + report, ok := reportMap[res.ManifestID] + if !ok { + report = &ManifestReport{ + ManifestID: res.ManifestID, + Target: res.Target, + Cases: []*CaseReport{}, + } + reportMap[res.ManifestID] = report + } + + man, ok := mansMap[res.ManifestID] + if ok { + report.Namespace = man.GetNamespace() + report.Name = man.GetName() + report.Kind = man.GetKind() + } + + 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, + Details: cr.Details, + Values: cr.Values, + } + + report.Cases = append(report.Cases, caseReport) + report.TotalCases++ + report.TotalTime += cr.Duration + + if cr.Success { + report.PassedCases++ + passedCases++ + } else { + report.FailedCases++ + failedCases++ + } + + totalCases++ + totalTime += cr.Duration + } + + manifestStats := make([]*ManifestReport, 0, len(reportMap)) + for _, m := range reportMap { + manifestStats = append(manifestStats, m) + } + + return &ViewData{ + GeneratedAt: time.Now(), + TotalCases: totalCases, + PassedCases: passedCases, + FailedCases: failedCases, + TotalTime: totalTime, + ManifestStats: manifestStats, + } +} + +// ReportGenerator generates an HTML report using html/template and gohtml templates. +type ReportGenerator struct { + tmpl *template.Template + funcs template.FuncMap +} + +// NewHTMLReportGenerator creates a new HTMLReportGenerator with custom functions and template directory. +func NewHTMLReportGenerator() (*ReportGenerator, error) { + funcs := template.FuncMap{ + "formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, + "formatDuration": func(d time.Duration) string { return d.String() }, + "statusText": func(success bool) string { + if success { + return "PASSED" + } + return "FAILED" + }, + "assertText": func(assert string) string { + if assert == "yes" { + return "PASSED" + } + return "FAILED" + }, + "float64": func(i int) float64 { return float64(i) }, + "div": func(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b + }, + "mul": func(a, b float64) float64 { return a * b }, + "prettyJSON": func(v any) string { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "" + } + return string(data) + }, + } + + tmpl, err := template.New("base.gohtml").Funcs(funcs).ParseFS(htmlTemplates, "templates/*.gohtml") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + return &ReportGenerator{ + tmpl: tmpl, + funcs: funcs, + }, nil +} + +// Generate creates an HTML report from test results and writes it to outputPath. +func (g *ReportGenerator) Generate(ctx interfaces.ExecutionContext) error { + data := buildReportViewData(ctx) + + reportsDir := "reports" + if err := os.MkdirAll(reportsDir, 0755); err != nil { + return fmt.Errorf("failed to create reports directory: %w", err) + } + outputPath := filepath.Join(reportsDir, 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 index 845476e..2b5714e 100644 --- a/internal/report/html/templates/base.gohtml +++ b/internal/report/html/templates/base.gohtml @@ -1,5 +1,5 @@ {{/* - Base template for the test report + Base template for the test report (refactored, no manifest nav) */}} @@ -8,13 +8,15 @@ Test Report +
    -

    Test Report

    +

    API Qube Test Report

    Generated at: {{ formatTime .GeneratedAt }}
    @@ -24,12 +26,20 @@
  • Passed: {{ .PassedCases }}
  • Failed: {{ .FailedCases }}
  • Total Time: {{ .TotalTime }}
  • +
  • + Success Rate: + + {{ if eq .TotalCases 0 }}0%{{ else }}{{ printf "%.1f" (mul (div (float64 .PassedCases) (float64 .TotalCases)) 100) }}%{{ end }} + +
  • {{ range .ManifestStats }} - {{ template "manifest.gohtml" . }} +
    + {{ template "manifest.gohtml" . }} +
    {{ end }}
    diff --git a/internal/report/html/templates/case.gohtml b/internal/report/html/templates/case.gohtml index d6d0a37..182abae 100644 --- a/internal/report/html/templates/case.gohtml +++ b/internal/report/html/templates/case.gohtml @@ -1,23 +1,88 @@ {{/* - Case component for the test report + Case component for the test report (refactored, pretty Request/Response) */}}
  • -
    -
    - {{ .Name }} - - {{ statusText .Success }} - - Method: {{ .Method }} - Status: {{ .StatusCode }} - Time: {{ .Duration }} +
    +
    +
    + {{ .Name }} + + + {{ statusText .Success }} + + + Assert: {{ assertText .Assert }} + + Method: {{ .Method }} + Status: {{ .StatusCode }} + Time: {{ .Duration }} +
    +
    -
    - {{ if .Errors }} -
      - {{ range .Errors }} -
    • {{ . }}
    • +
    - {{ end }} + {{ if .Response }} +
    + Response: + {{ if .Response.Headers }} +
    Headers:
    + + + + {{ range $k, $v := .Response.Headers }} + + {{ end }} + +
    KeyValue
    {{ $k }}{{ $v }}
    + {{ end }} + {{ if .Response.Body }} +
    Body:
    +
    {{ prettyJSON .Response.Body }}
    + {{ end }} +
    + {{ end }} +
    +
  • + diff --git a/internal/report/html/templates/manifest.gohtml b/internal/report/html/templates/manifest.gohtml index 22edef6..b5a1cff 100644 --- a/internal/report/html/templates/manifest.gohtml +++ b/internal/report/html/templates/manifest.gohtml @@ -1,18 +1,40 @@ {{/* - Manifest component for the test report + Manifest component for the test report (refactored, no Manifest block, Target moved) */}} -
    -

    Manifest: {{ .ManifestID }}

    -
    Target: {{ .Target }}
    -
    +
    +
    +
    + {{ .ManifestID }} + {{ .Kind }} + {{ .Namespace }} + {{ .Name }} + + {{ if eq .FailedCases 0 }} + + PASSED + {{ else }} + + FAILED + {{ end }} + +
    + +
    +
    Total: {{ .TotalCases }} Passed: {{ .PassedCases }} Failed: {{ .FailedCases }} - Time: {{ .TotalTime }} + Time: {{ formatDuration .TotalTime }} +
    +
    + Target: {{ .Target }} +
    + -
      - {{ range .Cases }} - {{ template "case.gohtml" . }} - {{ end }} -
    + diff --git a/internal/report/interfaces.go b/internal/report/interfaces.go index 5e4a8af..969aa07 100644 --- a/internal/report/interfaces.go +++ b/internal/report/interfaces.go @@ -1,7 +1,9 @@ package report -import "github.com/apiqube/cli/internal/core/runner/save" +import ( + "github.com/apiqube/cli/internal/core/runner/interfaces" +) type Generator interface { - Generate(results []*save.Result) error + Generate(ctx interfaces.ExecutionContext) error } diff --git a/internal/report/service.go b/internal/report/service.go index bbd0261..bbd3ab2 100644 --- a/internal/report/service.go +++ b/internal/report/service.go @@ -33,5 +33,5 @@ func (s *Service) CollectResults(ctx interfaces.ExecutionContext) []*save.Result } func (s *Service) GenerateReports(ctx interfaces.ExecutionContext) error { - return s.generator.Generate(s.CollectResults(ctx)) + return s.generator.Generate(ctx) } From 56b339427e5adebd8b4ba449915b980a47530b6b Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 20 Jun 2025 16:49:05 +0200 Subject: [PATCH 12/19] feat(runner): added V2 plan runner with dependency analysis and data passing system --- .gitignore | 3 + cmd/cli/run/run.go | 25 +- examples/complex-http-tests/http_test.yaml | 31 +- internal/core/manifests/kinds/tests/base.go | 7 +- internal/core/runner/depends/builder_v2.go | 94 ++++ .../core/runner/depends/graph_builder_v2.go | 462 ++++++++++++++++ internal/core/runner/depends/graph_test.go | 502 ++++++++++++++++++ internal/core/runner/depends/http_rules.go | 339 ++++++++++++ .../core/runner/depends/pass_integration.go | 304 +++++++++++ internal/core/runner/depends/rules.go | 248 +++++++++ .../core/runner/executor/executors/http.go | 6 +- internal/core/runner/executor/plan.go | 268 +++++++++- .../core/runner/form/directive_executor.go | 5 +- internal/core/runner/form/directives.go | 9 +- internal/core/runner/form/interfaces.go | 9 +- internal/core/runner/form/processors.go | 29 +- .../core/runner/form/reference_resolver.go | 21 +- internal/core/runner/form/runner.go | 42 +- .../core/runner/form/template_resolver.go | 3 +- internal/core/runner/plan/manager.go | 85 +++ 20 files changed, 2368 insertions(+), 124 deletions(-) create mode 100644 internal/core/runner/depends/builder_v2.go create mode 100644 internal/core/runner/depends/graph_builder_v2.go create mode 100644 internal/core/runner/depends/graph_test.go create mode 100644 internal/core/runner/depends/http_rules.go create mode 100644 internal/core/runner/depends/pass_integration.go create mode 100644 internal/core/runner/depends/rules.go diff --git a/.gitignore b/.gitignore index 490633f..c0a9476 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ bin .qodo cover.svg +# Temp lock +examples/complex-http-tests/reports + # Test binary, built with `go test -c` *.test diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go index 8aeff92..0051d69 100644 --- a/cmd/cli/run/run.go +++ b/cmd/cli/run/run.go @@ -50,18 +50,29 @@ var Cmd = &cobra.Command{ } cli.Success("All manifests valid") - cli.Info("Generating plan...") + cli.Info("Generating plan with V2 dependency system...") manager := runner.NewPlanManagerBuilder(). WithManifests(loadedManifests...).Build() - planManifest, err := manager.Generate() + // Use V2 plan generation with dependency analysis + planManifest, graphResult, err := manager.GenerateV2() if err != nil { - cli.Errorf("Failed to generate plan: %v", err) + cli.Errorf("Failed to generate V2 plan: %v", err) return } - cli.Successf("Plan successfully generated") + cli.Successf("V2 Plan successfully generated") + + // Print dependency analysis if verbose + if len(graphResult.SaveRequirements) > 0 { + cli.Info("Dependency analysis completed:") + for manifestID, req := range graphResult.SaveRequirements { + if req.Required { + cli.Infof(" %s will save data for: %v", manifestID, req.UsedBy) + } + } + } ctxBuilder := context.NewCtxBuilder(). WithContext(cmd.Context()). @@ -70,15 +81,17 @@ var Cmd = &cobra.Command{ registry := executor.NewDefaultExecutorRegistry() hooksRunner := hooks.NewDefaultHooksRunner() - planRunner := executor.NewDefaultPlanRunner(registry, hooksRunner) + // Use V2 plan runner with dependency support + planRunner := executor.NewV2PlanRunner(registry, hooksRunner, graphResult) runCtx := ctxBuilder.Build() if err = planRunner.RunPlan(runCtx, planManifest); err != nil { + cli.Errorf("Plan execution failed: %v", err) return } - cli.Successf("Plan successfully runned") + cli.Successf("V2 Plan successfully executed") }, } diff --git a/examples/complex-http-tests/http_test.yaml b/examples/complex-http-tests/http_test.yaml index 58bebcc..788ac36 100644 --- a/examples/complex-http-tests/http_test.yaml +++ b/examples/complex-http-tests/http_test.yaml @@ -8,26 +8,19 @@ spec: target: http://127.0.0.1:8081 cases: - - name: Create New Array of User + - name: Fetch User From Server + alias: fetch-user + method: GET + endpoint: /users/3 + assert: + - target: status + equals: 200 + + - name: Create User With Data From Previous Response method: POST - endpoint: /users-batch + endpoint: /users assert: - target: status - equals: 201 + equals: 200 body: - users: - __repeat: 2 - __template: - name: "{{ Fake.name }}" - email: "{{ Fake.email }}" - age: "{{ Fake.uint.10.100 }}" - address: - street: "{{ Fake.address }}" - 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 + user: "{{ fetch-user.response.body }}" \ 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 25ef721..c6b729a 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -6,6 +6,7 @@ import ( type HttpCase struct { Name string `yaml:"name" json:"name" validate:"required,min=3,max=128"` + Alias *string `yaml:"alias" json:"alias" validate:"omitempty,min=1,max=25"` Method string `yaml:"method" json:"method" valid:"required,uppercase,oneof=GET POST PUT PATCH DELETE"` Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"omitempty"` Url string `yaml:"url,omitempty" json:"url,omitempty" validate:"omitempty,url"` @@ -13,7 +14,6 @@ type HttpCase struct { 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,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"` 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"` @@ -36,8 +36,3 @@ type SaveEntry struct { Body map[string]string `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,min=1,max=20,dive,keys,endkeys"` Headers []string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,min=1,max=20"` } - -type Pass struct { - From string `yaml:"from" json:"from" validate:"required,min=1,max=100"` - Map map[string]string `yaml:"map,omitempty" json:"map,omitempty" validate:"omitempty,min=1,max=100,dive,keys,endkeys"` -} diff --git a/internal/core/runner/depends/builder_v2.go b/internal/core/runner/depends/builder_v2.go new file mode 100644 index 0000000..267e733 --- /dev/null +++ b/internal/core/runner/depends/builder_v2.go @@ -0,0 +1,94 @@ +package depends + +// GraphBuilderV2 is the new modular graph builder +type GraphBuilderV2 struct { + ruleRegistry *RuleRegistry +} + +func NewGraphBuilderV2(registry *RuleRegistry) *GraphBuilderV2 { + if registry == nil { + registry = DefaultRuleRegistry() + } + return &GraphBuilderV2{ + ruleRegistry: registry, + } +} + +// GraphResultV2 contains enhanced graph information +type GraphResultV2 struct { + Graph map[string][]string // adjacency list (inter-manifest only) + ExecutionOrder []string // topologically sorted order + Dependencies []Dependency // inter-manifest dependencies only + AllDependencies []Dependency // all discovered dependencies (inter + intra) + SaveRequirements map[string]SaveRequirement // what each manifest needs to save + Metadata map[string]map[string]any // additional metadata per manifest + IntraManifestDeps map[string][]Dependency // intra-manifest dependencies grouped by manifest +} + +// SaveRequirement defines what data a manifest should save for others +type SaveRequirement struct { + Required bool // whether saving is required + ManifestID string // ID of the manifest that provides data + RequiredPaths []string // specific paths to save (renamed from Paths for consistency) + Paths []string // alias for RequiredPaths for backward compatibility + UsedBy []string // which manifests will use this data (alias for Consumers) + Consumers []string // which manifests will consume this data +} + +// AddRule adds a new rule to the registry +func (gb *GraphBuilderV2) AddRule(rule DependencyRule) { + gb.ruleRegistry.Register(rule) +} + +// GetSaveRequirement returns save requirement for a manifest +func (gr *GraphResultV2) GetSaveRequirement(manifestID string) (SaveRequirement, bool) { + req, exists := gr.SaveRequirements[manifestID] + return req, exists +} + +// GetDependenciesFor returns all dependencies for a manifest +func (gr *GraphResultV2) GetDependenciesFor(manifestID string) []Dependency { + var deps []Dependency + for _, dep := range gr.Dependencies { + if dep.From == manifestID { + deps = append(deps, dep) + } + } + return deps +} + +// GetDependentsOf returns dependencies that depend on the given manifest +func (gr *GraphResultV2) GetDependentsOf(manifestID string) []Dependency { + var dependents []Dependency + for _, dep := range gr.Dependencies { + if dep.To == manifestID { + dependents = append(dependents, dep) + } + } + return dependents +} + +// GetDependenciesOf returns dependencies that the given manifest depends on +func (gr *GraphResultV2) GetDependenciesOf(manifestID string) []Dependency { + var dependencies []Dependency + for _, dep := range gr.Dependencies { + if dep.From == manifestID { + dependencies = append(dependencies, dep) + } + } + return dependencies +} + +// GetIntraManifestDependencies returns intra-manifest dependencies for a given manifest +func (gr *GraphResultV2) GetIntraManifestDependencies(manifestID string) []Dependency { + if deps, exists := gr.IntraManifestDeps[manifestID]; exists { + return deps + } + return []Dependency{} +} + +// HasIntraManifestDependencies checks if a manifest has intra-manifest dependencies +func (gr *GraphResultV2) HasIntraManifestDependencies(manifestID string) bool { + deps, exists := gr.IntraManifestDeps[manifestID] + return exists && len(deps) > 0 +} diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go new file mode 100644 index 0000000..b28dda8 --- /dev/null +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -0,0 +1,462 @@ +package depends + +import ( + "container/heap" + "fmt" + "github.com/apiqube/cli/internal/core/manifests/utils" + "strings" + + "github.com/apiqube/cli/internal/collections" + "github.com/apiqube/cli/internal/core/manifests" +) + +var ( + priorities = map[string]int{ + manifests.ValuesKind: 1, + manifests.ServerKind: 10, + manifests.ServiceKind: 20, + manifests.HttpTestKind: 30, + manifests.HttpLoadTestKind: 40, + } +) + +// BuildGraphWithRules builds a dependency graph using registered rules +func (gb *GraphBuilderV2) BuildGraphWithRules(mans []manifests.Manifest) (*GraphResultV2, error) { + if len(mans) == 0 { + return &GraphResultV2{ + Graph: make(map[string][]string), + ExecutionOrder: []string{}, + Dependencies: []Dependency{}, + AllDependencies: []Dependency{}, + SaveRequirements: make(map[string]SaveRequirement), + Metadata: make(map[string]map[string]any), + IntraManifestDeps: make(map[string][]Dependency), + }, nil + } + + // Step 1: Analyze dependencies using all rules + allDependencies, err := gb.analyzeDependencies(mans) + if err != nil { + return nil, fmt.Errorf("failed to analyze dependencies: %w", err) + } + + // Step 2: Separate inter-manifest and intra-manifest dependencies + manifestMap := make(map[string]manifests.Manifest) + for _, manifest := range mans { + manifestMap[manifest.GetID()] = manifest + } + + interManifestDeps, intraManifestDeps := gb.separateDependencies(allDependencies, manifestMap) + + // Step 3: Build graph from inter-manifest dependencies only + graph := gb.buildAdjacencyGraph(interManifestDeps, mans) + + // Step 4: Build execution order with topological sort + executionOrder, err := gb.buildExecutionOrder(mans, graph, interManifestDeps) + if err != nil { + return nil, fmt.Errorf("failed to build execution order: %w", err) + } + + // Step 5: Build save requirements (using all dependencies) + saveRequirements := gb.calculateSaveRequirements(allDependencies, mans) + + // Step 6: Build metadata + metadata := gb.collectMetadata(allDependencies, mans) + + // Step 7: Group intra-manifest dependencies by manifest + intraManifestDepsByManifest := gb.groupIntraManifestDeps(intraManifestDeps, manifestMap) + + return &GraphResultV2{ + Graph: graph, + ExecutionOrder: executionOrder, + Dependencies: interManifestDeps, + AllDependencies: allDependencies, + SaveRequirements: saveRequirements, + Metadata: metadata, + IntraManifestDeps: intraManifestDepsByManifest, + }, nil +} + +// separateDependencies separates inter-manifest and intra-manifest dependencies +func (gb *GraphBuilderV2) separateDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) ([]Dependency, []Dependency) { + var interManifestDeps []Dependency + var intraManifestDeps []Dependency + + for _, dep := range dependencies { + if gb.isIntraManifestDependency(dep, manifestMap) { + // This is an intra-manifest dependency (e.g., test case aliases) + intraManifestDeps = append(intraManifestDeps, dep) + } else { + // This is an inter-manifest dependency + interManifestDeps = append(interManifestDeps, dep) + } + } + + return interManifestDeps, intraManifestDeps +} + +// groupIntraManifestDeps groups intra-manifest dependencies by base manifest ID +func (gb *GraphBuilderV2) groupIntraManifestDeps(intraManifestDeps []Dependency, manifestMap map[string]manifests.Manifest) map[string][]Dependency { + grouped := make(map[string][]Dependency) + + for _, dep := range intraManifestDeps { + baseID := gb.getBaseManifestID(dep.From) + grouped[baseID] = append(grouped[baseID], dep) + } + + return grouped +} + +// isIntraManifestDependency checks if a dependency is within the same manifest +func (gb *GraphBuilderV2) isIntraManifestDependency(dep Dependency, manifestMap map[string]manifests.Manifest) bool { + // Extract base manifest ID (without alias/fragment) + fromBase := gb.getBaseManifestID(dep.From) + toBase := gb.getBaseManifestID(dep.To) + + // If both refer to the same base manifest, it's intra-manifest + return fromBase == toBase +} + +// getBaseManifestID extracts the base manifest ID without alias/fragment +func (gb *GraphBuilderV2) getBaseManifestID(id string) string { + // Remove fragment part (after #) + if idx := strings.Index(id, "#"); idx != -1 { + return id[:idx] + } + return id +} + +// buildAdjacencyGraph creates adjacency list from dependencies +func (gb *GraphBuilderV2) buildAdjacencyGraph(dependencies []Dependency, manifests []manifests.Manifest) map[string][]string { + graph := make(map[string][]string) + + // Initialize all manifests in the graph + for _, manifest := range manifests { + id := manifest.GetID() + graph[id] = []string{} + } + + // Add edges from dependencies + for _, dep := range dependencies { + // Only add edges for dependencies where both nodes exist as manifests + fromBase := gb.getBaseManifestID(dep.From) + toBase := gb.getBaseManifestID(dep.To) + + // Check if both base IDs exist in our manifest map + fromExists := false + toExists := false + + for _, manifest := range manifests { + if manifest.GetID() == fromBase { + fromExists = true + } + if manifest.GetID() == toBase { + toExists = true + } + } + + if fromExists && toExists { + // Add edge: To -> From (dependency direction) + graph[toBase] = append(graph[toBase], fromBase) + } + } + + return graph +} + +// buildExecutionOrder creates topologically sorted execution order +func (gb *GraphBuilderV2) buildExecutionOrder(mans []manifests.Manifest, graph map[string][]string, dependencies []Dependency) ([]string, error) { + // Calculate in-degrees + inDegree := make(map[string]int) + idToManifest := make(map[string]manifests.Manifest) + nodePriority := make(map[string]int) + + // Initialize + for _, manifest := range mans { + id := manifest.GetID() + idToManifest[id] = manifest + inDegree[id] = 0 + nodePriority[id] = gb.getManifestPriority(manifest) + } + + // Calculate in-degrees from dependencies + for _, dep := range dependencies { + fromBase := gb.getBaseManifestID(dep.From) + if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { + inDegree[fromBase]++ + } + } + + // Priority queue for topological sort + priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { + return a.Priority > b.Priority + }) + + // Add nodes with no dependencies + for id, degree := range inDegree { + if degree == 0 { + heap.Push(priorityQueue, &Node{ + ID: id, + Priority: nodePriority[id], + }) + } + } + + var order []string + for priorityQueue.Len() > 0 { + current := heap.Pop(priorityQueue).(*Node).ID + order = append(order, current) + + // Process neighbors + for _, neighbor := range graph[current] { + if inDegree[neighbor] > 0 { + inDegree[neighbor]-- + if inDegree[neighbor] == 0 { + heap.Push(priorityQueue, &Node{ + ID: neighbor, + Priority: nodePriority[neighbor], + }) + } + } + } + } + + // Check for cycles + if len(order) != len(mans) { + cyclicNodes := gb.findCyclicNodes(inDegree) + return nil, fmt.Errorf("cyclic dependency detected: %v", cyclicNodes) + } + + return order, nil +} + +// getManifestPriorityByID gets priority by manifest ID +func (gb *GraphBuilderV2) getManifestPriorityByID(manifestID string) int { + // Extract kind from ID (assuming format: namespace.kind.name) + _, kind, _ := utils.ParseManifestID(manifestID) + return gb.getKindPriority(kind) +} + +// getKindPriority returns priority for a manifest kind +func (gb *GraphBuilderV2) getKindPriority(kind string) int { + if priority, ok := priorities[kind]; ok { + return priority + } + + return 1_000 +} + +// analyzeDependencies runs all rules to discover dependencies +func (gb *GraphBuilderV2) analyzeDependencies(manifests []manifests.Manifest) ([]Dependency, error) { + var allDependencies []Dependency + + for _, manifest := range manifests { + for _, rule := range gb.ruleRegistry.GetRules() { + if !rule.CanHandle(manifest) { + continue + } + + dependencies, err := rule.AnalyzeDependencies(manifest) + if err != nil { + return nil, fmt.Errorf("rule %s failed for manifest %s: %w", + rule.Name(), manifest.GetID(), err) + } + + allDependencies = append(allDependencies, dependencies...) + } + } + + return gb.deduplicateDependencies(allDependencies), nil +} + +// deduplicateDependencies removes duplicate dependencies +func (gb *GraphBuilderV2) deduplicateDependencies(deps []Dependency) []Dependency { + seen := make(map[string]bool) + var result []Dependency + + for _, dep := range deps { + key := fmt.Sprintf("%s->%s:%s", dep.From, dep.To, dep.Type) + if !seen[key] { + seen[key] = true + result = append(result, dep) + } + } + + return result +} + +// calculateSaveRequirements determines what each manifest needs to save +func (gb *GraphBuilderV2) calculateSaveRequirements(dependencies []Dependency, manifests []manifests.Manifest) map[string]SaveRequirement { + requirements := make(map[string]SaveRequirement) + + // Initialize all manifests with no save requirement + for _, manifest := range manifests { + requirements[manifest.GetID()] = SaveRequirement{ + Required: false, + ManifestID: manifest.GetID(), + RequiredPaths: []string{}, + Paths: []string{}, + UsedBy: []string{}, + Consumers: []string{}, + } + } + + // Process template dependencies to determine save requirements + for _, dep := range dependencies { + if dep.Type == DependencyTypeTemplate { + toBase := gb.getBaseManifestID(dep.To) + req := requirements[toBase] + req.Required = true + req.UsedBy = append(req.UsedBy, dep.From) + req.Consumers = append(req.Consumers, dep.From) + + // Add required paths from metadata + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + req.RequiredPaths = append(req.RequiredPaths, paths...) + req.Paths = append(req.Paths, paths...) // for backward compatibility + } + + requirements[toBase] = req + } + } + + // Remove duplicates from paths and consumers + for id, req := range requirements { + req.RequiredPaths = gb.removeDuplicateStrings(req.RequiredPaths) + req.Paths = gb.removeDuplicateStrings(req.Paths) + req.UsedBy = gb.removeDuplicateStrings(req.UsedBy) + req.Consumers = gb.removeDuplicateStrings(req.Consumers) + requirements[id] = req + } + + return requirements +} + +// collectMetadata gathers metadata from all dependencies +func (gb *GraphBuilderV2) collectMetadata(dependencies []Dependency, manifests []manifests.Manifest) map[string]map[string]any { + metadata := make(map[string]map[string]any) + + // Initialize metadata for all manifests + for _, manifest := range manifests { + metadata[manifest.GetID()] = make(map[string]any) + } + + // Collect metadata from dependencies + for _, dep := range dependencies { + fromBase := gb.getBaseManifestID(dep.From) + if dep.Metadata != nil { + if metadata[fromBase] == nil { + metadata[fromBase] = make(map[string]any) + } + manifestMeta := metadata[fromBase] + for key, value := range dep.Metadata { + manifestMeta[key] = value + } + } + } + + return metadata +} + +// getManifestPriority calculates priority for a manifest +func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { + // Try to find KindPriorityRule + for _, rule := range gb.ruleRegistry.GetRules() { + if kindRule, ok := rule.(*KindPriorityRule); ok { + return kindRule.GetKindPriority(manifest.GetKind()) + } + } + + // Fallback to direct priority calculation + return gb.getKindPriority(manifest.GetKind()) +} + +// findCyclicNodes finds nodes involved in cycles +func (gb *GraphBuilderV2) findCyclicNodes(inDegree map[string]int) []string { + var cyclicNodes []string + for id, degree := range inDegree { + if degree > 0 { + cyclicNodes = append(cyclicNodes, id) + } + } + return cyclicNodes +} + +// removeDuplicateStrings removes duplicate strings from a slice +func (gb *GraphBuilderV2) removeDuplicateStrings(slice []string) []string { + keys := make(map[string]bool) + var result []string + + for _, item := range slice { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + + return result +} + +// Legacy methods for backward compatibility +func (gb *GraphBuilderV2) filterIntraManifestDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) []Dependency { + interManifestDeps, _ := gb.separateDependencies(dependencies, manifestMap) + return interManifestDeps +} + +func (gb *GraphBuilderV2) buildSaveRequirements(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]SaveRequirement { + requirements := make(map[string]SaveRequirement) + + // Group dependencies by target (what provides the data) + providerMap := make(map[string][]Dependency) + for _, dep := range dependencies { + if dep.Type == DependencyTypeTemplate { + providerMap[dep.To] = append(providerMap[dep.To], dep) + } + } + + // Build save requirements + for providerID, deps := range providerMap { + var requiredPaths []string + var consumers []string + + for _, dep := range deps { + consumers = append(consumers, dep.From) + + // Extract required paths from metadata + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + requiredPaths = append(requiredPaths, paths...) + } + } + + // Remove duplicates + requiredPaths = gb.removeDuplicateStrings(requiredPaths) + consumers = gb.removeDuplicateStrings(consumers) + + requirements[providerID] = SaveRequirement{ + Required: true, + ManifestID: providerID, + RequiredPaths: requiredPaths, + Paths: requiredPaths, // for backward compatibility + UsedBy: consumers, // for backward compatibility + Consumers: consumers, + } + } + + return requirements +} + +func (gb *GraphBuilderV2) buildMetadata(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]map[string]any { + metadata := make(map[string]map[string]any) + + for _, dep := range dependencies { + if metadata[dep.From] == nil { + metadata[dep.From] = make(map[string]any) + } + + // Add dependency metadata + depKey := fmt.Sprintf("dep_%s", dep.To) + metadata[dep.From][depKey] = dep.Metadata + } + + return metadata +} diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go new file mode 100644 index 0000000..df80e73 --- /dev/null +++ b/internal/core/runner/depends/graph_test.go @@ -0,0 +1,502 @@ +package depends + +import ( + "fmt" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/apiqube/cli/internal/core/manifests/kinds/servers" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" + "github.com/apiqube/cli/internal/core/manifests/kinds/values" + "github.com/apiqube/cli/internal/core/manifests/utils" + "net/http" + "strings" + "testing" + + "github.com/apiqube/cli/internal/core/manifests" +) + +// TestGraphBuilder tests the graph building functionality +func TestGraphBuilder(t *testing.T) { + fmt.Println("πŸ§ͺ Running Graph Builder Tests") + fmt.Println(strings.Repeat("=", 10)) + + // Test Case 1: Simple manifests without dependencies + t.Run("Simple manifests without dependencies", func(t *testing.T) { + mans := []manifests.Manifest{ + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case 1", + Method: http.MethodGet, + Endpoint: "/api", + }, + }, + }, + }, + }, + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + result, err := builder.BuildGraphWithRules(mans) + if err != nil { + t.Fatalf("Failed to build graph: %v", err) + } + + // Print results + printDependencyGraph(builder, result) + + // Assertions + if len(result.ExecutionOrder) != 1 { + t.Errorf("Expected 1 manifest in execution order, got %d", len(result.ExecutionOrder)) + } + + // Values should come first due to higher priority + if result.ExecutionOrder[0] != mans[0].GetID() { + t.Errorf("Expected %s manifest id, got %s", mans[0].GetID(), result.ExecutionOrder[0]) + } + }) + + // Test Case 2: Manifests with explicit dependencies + t.Run("Manifests with explicit dependencies", func(t *testing.T) { + fmt.Println("\nπŸ“ Test Case 2: Manifests with explicit dependencies") + + mans := []manifests.Manifest{ + &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values-test", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` + }{ + Data: map[string]any{ + "data_1": 1, + "data_2": []int{1, 2, 3}, + }, + }, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case 1", + Method: http.MethodGet, + Endpoint: "/api", + }, + }, + }, + }, + Dependencies: kinds.Dependencies{ + DependsOn: []string{ + utils.FormManifestID(manifests.DefaultNamespace, manifests.ValuesKind, "values-test"), + }, + }, + }, + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + result, err := builder.BuildGraphWithRules(mans) + if err != nil { + t.Fatalf("Failed to build graph: %v", err) + } + + // Print results + printDependencyGraph(builder, result) + + // Assertions + if len(result.Dependencies) != 1 { + t.Errorf("Expected 1 dependency, got %d", len(result.Dependencies)) + } + + // Values should come first + if result.ExecutionOrder[0] != mans[0].GetID() { + t.Errorf("Expected Values manifest first, got %s", result.ExecutionOrder[0]) + } + }) + + // Test Case 3: Single manifest with intra-manifest dependencies (your case) + t.Run("Single manifest with intra-manifest dependencies", func(t *testing.T) { + fmt.Println("\nπŸ“ Test Case 3: Single manifest with intra-manifest dependencies") + + // Create a mock HTTP test with internal test case dependencies + mans := []manifests.Manifest{ + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case", + Alias: stringPtr("fetch-users"), + Method: http.MethodGet, + Endpoint: "/users", + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Internal Dependencies", + Method: http.MethodDelete, + Endpoint: "/users/{{ fetch-users.response.body.users.0.id }}", + Body: map[string]any{ + "name": "{{ fetch-users.response.body.users.0.name }}", + }, + }, + }, + }, + }, + }, + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + result, err := builder.BuildGraphWithRules(mans) + if err != nil { + t.Fatalf("Failed to build graph: %v", err) + } + + // Print results + printDependencyGraph(builder, result) + + // Assertions + if len(result.ExecutionOrder) != 1 { + t.Errorf("Expected 1 manifest in execution order, got %d", len(result.ExecutionOrder)) + } + + // Should have intra-manifest dependencies + if len(result.IntraManifestDeps) == 0 { + t.Errorf("Expected intra-manifest dependencies, got none") + } + + // Should have no inter-manifest dependencies (no cycles) + if len(result.Dependencies) != 1 { + t.Errorf("Expected no inter-manifest dependencies, got %d", len(result.Dependencies)) + } + }) + + // Test Case 4: Multiple manifests with mixed dependencies + t.Run("Multiple manifests with mixed dependencies", func(t *testing.T) { + fmt.Println("\nπŸ“ Test Case 4: Multiple manifests with mixed dependencies") + + mans := []manifests.Manifest{ + &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values-test", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` + }{ + Data: map[string]any{ + "data_1": 1, + "data_2": []int{1, 2, 3}, + "user": struct { + Name string + Email string + }{ + Name: "user_1", + Email: "user_1@example.com", + }, + }, + }, + }, + &servers.Server{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServerKind, + Metadata: kinds.Metadata{ + Name: "http-test-server", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + BaseURL string `yaml:"baseUrl" json:"baseUrl" validate:"required,url"` + Health string `yaml:"health" json:"health" validate:"omitempty,max=100"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers" validate:"omitempty,max=20"` + }{ + BaseURL: "http://127.0.0.1:8080", + Health: "", + Headers: map[string]string{ + "Content-Type": "application/json", + }}, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test-roles", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case", + Alias: stringPtr("users-roles"), + Method: http.MethodGet, + Endpoint: "/roles", + }, + }, + }, + }, + Dependencies: kinds.Dependencies{ + DependsOn: []string{ + utils.FormManifestID(manifests.DefaultNamespace, manifests.ServerKind, "http-test-server"), + }, + }, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test-users", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case", + Alias: stringPtr("fetch-users"), + Method: http.MethodGet, + Endpoint: "/users", + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Internal Dependencies", + Method: http.MethodDelete, + Endpoint: "/users/{{ fetch-users.response.body.users.0.id }}", + Body: map[string]any{ + "name": "{{ fetch-users.response.body.users.0.name }}", + }, + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Explicit Values Dependencies", + Method: http.MethodPost, + Endpoint: "/users", + Body: map[string]any{ + "user": "{{ Values.values-test.user }}", + "role": "{{ users.roles.roles.3 }}", + }, + }, + }, + }, + }, + Dependencies: kinds.Dependencies{ + DependsOn: []string{ + utils.FormManifestID(manifests.DefaultNamespace, manifests.ValuesKind, "values-test"), + utils.FormManifestID(manifests.DefaultNamespace, manifests.ServerKind, "http-test-server"), + }, + }, + }, + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + result, err := builder.BuildGraphWithRules(mans) + if err != nil { + t.Fatalf("Failed to build graph: %v", err) + } + + // Print results + printDependencyGraph(builder, result) + + // Assertions + if len(result.ExecutionOrder) != 3 { + t.Errorf("Expected 3 manifests in execution order, got %d", len(result.ExecutionOrder)) + } + + // Check execution order (Values -> Server -> HttpTest & HttpTest) + expectedOrder := []string{ + mans[0].GetID(), + mans[1].GetID(), + mans[2].GetID(), + mans[3].GetID(), + } + + for i, expected := range expectedOrder { + if result.ExecutionOrder[i] != expected { + t.Errorf("Expected %s at position %d, got %s", expected, i, result.ExecutionOrder[i]) + } + } + }) +} + +/* +// BenchmarkGraphBuilder benchmarks the graph building performance +func BenchmarkGraphBuilder(b *testing.B) { + // Create a large set of manifests for benchmarking + var manifests []manifests.Manifest + + for i := 0; i < 100; i++ { + manifests = append(manifests, &MockManifest{ + ID: fmt.Sprintf("test.HttpTest.test-%d", i), + Kind: manifests.HttpTestKind, + }) + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := builder.BuildGraphWithRules(manifests) + if err != nil { + b.Fatalf("Failed to build graph: %v", err) + } + } +} +*/ + +// PrintDependencyGraph prints a beautiful visualization of the dependency graph +func printDependencyGraph(gb *GraphBuilderV2, result *GraphResultV2) { + fmt.Println("\n" + strings.Repeat("=", 80)) + fmt.Println("πŸ”— DEPENDENCY GRAPH VISUALIZATION") + fmt.Println(strings.Repeat("=", 80)) + + // Print execution order + fmt.Println("\nπŸ“‹ EXECUTION ORDER:") + fmt.Println(strings.Repeat("-", 40)) + for i, manifestID := range result.ExecutionOrder { + priority := gb.getManifestPriorityByID(manifestID) + fmt.Printf(" %d. %s (priority: %d)\n", i+1, manifestID, priority) + } + + // Print inter-manifest dependencies + fmt.Println("\nπŸ”„ INTER-MANIFEST DEPENDENCIES:") + fmt.Println(strings.Repeat("-", 40)) + if len(result.Dependencies) == 0 { + fmt.Println(" βœ… No inter-manifest dependencies found") + } else { + for _, dep := range result.Dependencies { + fmt.Printf(" %s ──(%s)──> %s\n", dep.From, dep.Type, dep.To) + if dep.Metadata != nil { + for key, value := range dep.Metadata { + fmt.Printf(" └─ %s: %v\n", key, value) + } + } + } + } + + // Print intra-manifest dependencies + fmt.Println("\n🏠 INTRA-MANIFEST DEPENDENCIES:") + fmt.Println(strings.Repeat("-", 40)) + if len(result.IntraManifestDeps) == 0 { + fmt.Println(" βœ… No intra-manifest dependencies found") + } else { + for manifestID, deps := range result.IntraManifestDeps { + fmt.Printf(" πŸ“¦ %s:\n", manifestID) + for _, dep := range deps { + fmt.Printf(" %s ──(%s)──> %s\n", dep.From, dep.Type, dep.To) + if dep.Metadata != nil { + for key, value := range dep.Metadata { + fmt.Printf(" └─ %s: %v\n", key, value) + } + } + } + } + } + + // Print save requirements + fmt.Println("\nπŸ’Ύ SAVE REQUIREMENTS:") + fmt.Println(strings.Repeat("-", 40)) + hasRequirements := false + for manifestID, req := range result.SaveRequirements { + if req.Required { + hasRequirements = true + fmt.Printf(" πŸ“ %s:\n", manifestID) + fmt.Printf(" └─ Paths: %v\n", req.RequiredPaths) + fmt.Printf(" └─ Used by: %v\n", req.Consumers) + } + } + if !hasRequirements { + fmt.Println(" βœ… No save requirements found") + } + + // Print graph adjacency list + fmt.Println("\nπŸ•ΈοΈ ADJACENCY GRAPH:") + fmt.Println(strings.Repeat("-", 40)) + for head, tails := range result.Graph { + if len(tails) > 0 { + fmt.Printf(" %s:\n", head) + for _, tail := range tails { + fmt.Printf(" └─ %s\n", tail) + } + } else { + fmt.Printf(" %s: (no dependencies)\n", head) + } + } + + fmt.Println("\n" + strings.Repeat("=", 80)) +} + +// Helper function +func stringPtr(s string) *string { + return &s +} diff --git a/internal/core/runner/depends/http_rules.go b/internal/core/runner/depends/http_rules.go new file mode 100644 index 0000000..212e677 --- /dev/null +++ b/internal/core/runner/depends/http_rules.go @@ -0,0 +1,339 @@ +package depends + +import ( + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" + "reflect" + "regexp" +) + +// HttpTestDependencyRule handles HTTP test specific dependencies +type HttpTestDependencyRule struct { + templateRegex *regexp.Regexp +} + +func NewHttpTestDependencyRule() *HttpTestDependencyRule { + // Enhanced regex to capture various template patterns + regex := regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`) + return &HttpTestDependencyRule{ + templateRegex: regex, + } +} + +func (r *HttpTestDependencyRule) Name() string { + return "http_test" +} + +func (r *HttpTestDependencyRule) CanHandle(manifest manifests.Manifest) bool { + _, ok := manifest.(*api.Http) + return ok +} + +func (r *HttpTestDependencyRule) AnalyzeDependencies(manifest manifests.Manifest) ([]Dependency, error) { + httpTest, ok := manifest.(*api.Http) + if !ok { + return nil, nil + } + + var dependencies []Dependency + fromID := manifest.GetID() + + // Analyze each test case + for _, testCase := range httpTest.Spec.Cases { + caseDeps := r.analyzeTestCase(fromID, testCase, httpTest) + dependencies = append(dependencies, caseDeps...) + } + + return dependencies, nil +} + +func (r *HttpTestDependencyRule) GetPriority() int { + return 60 // Higher than generic template rule +} + +// analyzeTestCase analyzes a single HTTP test case for dependencies +func (r *HttpTestDependencyRule) analyzeTestCase(manifestID string, testCase api.HttpCase, httpTest *api.Http) []Dependency { + var dependencies []Dependency + + // Collect all template references from the test case + references := r.extractAllReferences(testCase) + + // Group references by alias + aliasRefs := make(map[string][]HttpTemplateReference) + for _, ref := range references { + aliasRefs[ref.Alias] = append(aliasRefs[ref.Alias], ref) + } + + // Create dependencies for each referenced alias + for alias, refs := range aliasRefs { + toID := r.resolveAliasToManifestID(httpTest, alias) + + // Collect all required paths for this alias + var requiredPaths []string + var locations []string + + for _, ref := range refs { + requiredPaths = append(requiredPaths, ref.Path) + locations = append(locations, ref.Location) + } + + dependency := Dependency{ + From: manifestID, + To: toID, + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": requiredPaths, + "locations": locations, + "save_required": true, + "test_case_name": testCase.Name, + "source_manifest": httpTest.GetKind(), + }, + } + + dependencies = append(dependencies, dependency) + } + + return dependencies +} + +// HttpTemplateReference represents a template reference in HTTP test +type HttpTemplateReference struct { + Alias string // e.g., "users-list" + Path string // e.g., "response.body.data[0].id" + Location string // where it was found (e.g., "body.user_id", "endpoint") +} + +// extractAllReferences finds all template references in a test case +func (r *HttpTestDependencyRule) extractAllReferences(testCase api.HttpCase) []HttpTemplateReference { + var references []HttpTemplateReference + + // Check endpoint + if refs := r.findReferencesInString(testCase.Endpoint, "endpoint"); len(refs) > 0 { + references = append(references, refs...) + } + + // Check URL + if refs := r.findReferencesInString(testCase.Url, "url"); len(refs) > 0 { + references = append(references, refs...) + } + + // Check headers + for key, value := range testCase.Headers { + location := fmt.Sprintf("headers.%s", key) + if refs := r.findReferencesInString(value, location); len(refs) > 0 { + references = append(references, refs...) + } + } + + // Check body (recursively) + if testCase.Body != nil { + bodyRefs := r.findReferencesInValue(testCase.Body, "body") + references = append(references, bodyRefs...) + } + + // Check assertions + for i, assert := range testCase.Assert { + location := fmt.Sprintf("assert[%d]", i) + if assert.Template != "" { + if refs := r.findReferencesInString(assert.Template, location+".template"); len(refs) > 0 { + references = append(references, refs...) + } + } + } + + return references +} + +// findReferencesInString extracts template references from a string +func (r *HttpTestDependencyRule) findReferencesInString(str, location string) []HttpTemplateReference { + var references []HttpTemplateReference + + matches := r.templateRegex.FindAllStringSubmatch(str, -1) + for _, match := range matches { + if len(match) >= 3 { + references = append(references, HttpTemplateReference{ + Alias: match[1], + Path: match[2], + Location: location, + }) + } + } + + return references +} + +// findReferencesInValue recursively finds references in any value type +func (r *HttpTestDependencyRule) findReferencesInValue(value any, location string) []HttpTemplateReference { + var references []HttpTemplateReference + + switch v := value.(type) { + case string: + references = append(references, r.findReferencesInString(v, location)...) + case map[string]any: + for key, val := range v { + newLocation := fmt.Sprintf("%s.%s", location, key) + references = append(references, r.findReferencesInValue(val, newLocation)...) + } + case []any: + for i, val := range v { + newLocation := fmt.Sprintf("%s[%d]", location, i) + references = append(references, r.findReferencesInValue(val, newLocation)...) + } + case map[any]any: + // Handle interface{} maps + for key, val := range v { + keyStr := fmt.Sprintf("%v", key) + newLocation := fmt.Sprintf("%s.%s", location, keyStr) + references = append(references, r.findReferencesInValue(val, newLocation)...) + } + } + + return references +} + +// resolveAliasToManifestID converts test case alias to full manifest ID +func (r *HttpTestDependencyRule) resolveAliasToManifestID(httpTest *api.Http, alias string) string { + // For HTTP tests, we assume the alias refers to another test case in the same manifest + // Format: namespace.kind.name#alias + baseID := httpTest.GetID() + return fmt.Sprintf("%s#%s", baseID, alias) +} + +// IntraManifestDependencyRule handles dependencies within the same manifest +type IntraManifestDependencyRule struct{} + +func NewIntraManifestDependencyRule() *IntraManifestDependencyRule { + return &IntraManifestDependencyRule{} +} + +func (r *IntraManifestDependencyRule) Name() string { + return "intra_manifest" +} + +func (r *IntraManifestDependencyRule) CanHandle(manifest manifests.Manifest) bool { + _, ok := manifest.(*api.Http) + return ok +} + +func (r *IntraManifestDependencyRule) AnalyzeDependencies(manifest manifests.Manifest) ([]Dependency, error) { + httpTest, ok := manifest.(*api.Http) + if !ok { + return nil, nil + } + + var dependencies []Dependency + manifestID := manifest.GetID() + + // Create a map of aliases to test cases + aliasToCase := make(map[string]api.HttpCase) + for _, testCase := range httpTest.Spec.Cases { + if testCase.Alias != nil { + aliasToCase[*testCase.Alias] = testCase + } + } + + // Analyze dependencies between test cases + for i, testCase := range httpTest.Spec.Cases { + caseID := fmt.Sprintf("%s#case_%d", manifestID, i) + if testCase.Alias != nil { + caseID = fmt.Sprintf("%s#%s", manifestID, *testCase.Alias) + } + + // Find references to other test cases + references := r.findIntraManifestReferences(testCase) + + for _, ref := range references { + if _, exists := aliasToCase[ref.Alias]; exists { + depID := fmt.Sprintf("%s#%s", manifestID, ref.Alias) + + dependency := Dependency{ + From: caseID, + To: depID, + Type: DependencyTypeValue, + Metadata: map[string]any{ + "alias": ref.Alias, + "required_paths": []string{ref.Path}, + "save_required": true, + "intra_manifest": true, + }, + } + + dependencies = append(dependencies, dependency) + } + } + } + + return dependencies, nil +} + +func (r *IntraManifestDependencyRule) GetPriority() int { + return 70 // Higher priority for intra-manifest dependencies +} + +// findIntraManifestReferences finds references to other test cases within the same manifest +func (r *IntraManifestDependencyRule) findIntraManifestReferences(testCase api.HttpCase) []HttpTemplateReference { + var references []HttpTemplateReference + + // Use reflection to traverse all string fields + r.findReferencesInStruct(reflect.ValueOf(testCase), "", &references) + + return references +} + +// findReferencesInStruct recursively finds template references in struct fields +func (r *IntraManifestDependencyRule) findReferencesInStruct(v reflect.Value, path string, references *[]HttpTemplateReference) { + templateRegex := regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`) + + switch v.Kind() { + case reflect.String: + str := v.String() + matches := templateRegex.FindAllStringSubmatch(str, -1) + for _, match := range matches { + if len(match) >= 3 { + *references = append(*references, HttpTemplateReference{ + Alias: match[1], + Path: match[2], + Location: path, + }) + } + } + case reflect.Map: + for _, key := range v.MapKeys() { + keyStr := fmt.Sprintf("%v", key.Interface()) + newPath := path + if path != "" { + newPath = fmt.Sprintf("%s.%s", path, keyStr) + } else { + newPath = keyStr + } + r.findReferencesInStruct(v.MapIndex(key), newPath, references) + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + newPath := fmt.Sprintf("%s[%d]", path, i) + r.findReferencesInStruct(v.Index(i), newPath, references) + } + case reflect.Struct: + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + if field.IsExported() { + newPath := path + if path != "" { + newPath = fmt.Sprintf("%s.%s", path, field.Name) + } else { + newPath = field.Name + } + r.findReferencesInStruct(v.Field(i), newPath, references) + } + } + case reflect.Ptr, reflect.Interface: + if !v.IsNil() { + r.findReferencesInStruct(v.Elem(), path, references) + } + default: + // Skip unsupported types + } +} diff --git a/internal/core/runner/depends/pass_integration.go b/internal/core/runner/depends/pass_integration.go new file mode 100644 index 0000000..aa52a9a --- /dev/null +++ b/internal/core/runner/depends/pass_integration.go @@ -0,0 +1,304 @@ +package depends + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// PassManager handles automatic data passing between tests +type PassManager struct { + ctx interfaces.ExecutionContext + saveRequirements map[string]SaveRequirement + graphResult *GraphResultV2 +} + +func NewPassManager(ctx interfaces.ExecutionContext, graphResult *GraphResultV2) *PassManager { + return &PassManager{ + ctx: ctx, + saveRequirements: graphResult.SaveRequirements, + graphResult: graphResult, + } +} + +// ShouldSaveResult determines if a test result should be saved for passing +func (pm *PassManager) ShouldSaveResult(manifestID string) bool { + req, exists := pm.saveRequirements[manifestID] + return exists && req.Required +} + +// SaveTestResult saves test result data for future use +func (pm *PassManager) SaveTestResult(manifestID string, result TestResult) error { + req, exists := pm.saveRequirements[manifestID] + if !exists || !req.Required { + return nil // No need to save + } + + // Save the complete result first + pm.ctx.Set(manifestID, result) + + // Save specific paths if required + for _, path := range req.Paths { + value, err := pm.extractValueByPath(result, path) + if err != nil { + // Log warning but don't fail - the path might be optional + continue + } + + key := fmt.Sprintf("%s.%s", manifestID, path) + pm.ctx.Set(key, value) + } + + // Send to PassStore channels for any waiting consumers + pm.notifyWaitingConsumers(manifestID, result) + + return nil +} + +// TestResult represents the result of a test execution +type TestResult struct { + Request RequestData `json:"request"` + Response ResponseData `json:"response"` + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Duration int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +type RequestData struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body any `json:"body"` +} + +type ResponseData struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body any `json:"body"` +} + +// extractValueByPath extracts a value from test result using dot notation path +func (pm *PassManager) extractValueByPath(result TestResult, path string) (any, error) { + // Convert result to map for easier navigation + resultMap := pm.structToMap(result) + + // Navigate the path + return pm.navigatePath(resultMap, path) +} + +// structToMap converts struct to map using JSON marshaling +func (pm *PassManager) structToMap(v any) map[string]any { + data, _ := json.Marshal(v) + var result map[string]any + _ = json.Unmarshal(data, &result) + return result +} + +// navigatePath navigates a dot-notation path in a map structure +func (pm *PassManager) navigatePath(data any, path string) (any, error) { + if path == "" || path == "*" { + return data, nil + } + + parts := strings.Split(path, ".") + current := data + + for _, part := range parts { + // Handle array indexing like "data[0]" or "data[-1]" + if strings.Contains(part, "[") && strings.Contains(part, "]") { + current = pm.handleArrayAccess(current, part) + if current == nil { + return nil, fmt.Errorf("array access failed for part: %s", part) + } + } else { + // Handle map access + if m, ok := current.(map[string]any); ok { + if val, exists := m[part]; exists { + current = val + } else { + return nil, fmt.Errorf("path not found: %s", part) + } + } else { + return nil, fmt.Errorf("cannot access field %s on non-map type", part) + } + } + } + + return current, nil +} + +// handleArrayAccess handles array access patterns like "data[0]", "data[-1]", "data[*]" +func (pm *PassManager) handleArrayAccess(data any, part string) any { + // Extract field name and index + openBracket := strings.Index(part, "[") + closeBracket := strings.Index(part, "]") + + if openBracket == -1 || closeBracket == -1 { + return nil + } + + fieldName := part[:openBracket] + indexStr := part[openBracket+1 : closeBracket] + + // Get the field first + var fieldValue any + if fieldName != "" { + if m, ok := data.(map[string]any); ok { + if val, exists := m[fieldName]; exists { + fieldValue = val + } else { + return nil + } + } else { + return nil + } + } else { + fieldValue = data + } + + // Handle array access + if arr, ok := fieldValue.([]any); ok { + return pm.accessArray(arr, indexStr) + } + + return nil +} + +// accessArray handles different array access patterns +func (pm *PassManager) accessArray(arr []any, indexStr string) any { + switch indexStr { + case "*": + // Return entire array + return arr + case "-1": + // Return last element + if len(arr) > 0 { + return arr[len(arr)-1] + } + return nil + default: + // Try to parse as integer index + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil { + return nil + } + + // Handle negative indices + if index < 0 { + index = len(arr) + index + } + + if index >= 0 && index < len(arr) { + return arr[index] + } + return nil + } +} + +// notifyWaitingConsumers sends data to PassStore channels +func (pm *PassManager) notifyWaitingConsumers(manifestID string, result TestResult) { + // Get dependents of this manifest + dependents := pm.graphResult.GetDependentsOf(manifestID) + + for _, dep := range dependents { + if dep.Type == DependencyTypeTemplate || dep.Type == DependencyTypeValue { + // Send complete result + pm.ctx.SafeSend(manifestID, result) + + // Send specific paths if specified in metadata + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + for _, path := range paths { + if value, err := pm.extractValueByPath(result, path); err == nil { + key := fmt.Sprintf("%s.%s", manifestID, path) + pm.ctx.SafeSend(key, value) + } + } + } + } + } +} + +// WaitForDependency waits for a dependency to be available +func (pm *PassManager) WaitForDependency(manifestID, dependencyAlias, path string) (any, error) { + // Try to get from DataStore first (synchronous) + key := fmt.Sprintf("%s.%s", dependencyAlias, path) + if value, exists := pm.ctx.Get(key); exists { + return value, nil + } + + // If not available, wait on channel (asynchronous) + ch := pm.ctx.Channel(key) + select { + case value := <-ch: + return value, nil + case <-pm.ctx.Done(): + return nil, fmt.Errorf("context cancelled while waiting for dependency %s", key) + } +} + +// GetDependencyValue gets a dependency value with fallback to waiting +func (pm *PassManager) GetDependencyValue(manifestID, dependencyAlias, path string) (any, error) { + // First try direct access + fullKey := fmt.Sprintf("%s.%s", dependencyAlias, path) + if value, exists := pm.ctx.Get(fullKey); exists { + return value, nil + } + + // Try getting the full result and extracting the path + if result, exists := pm.ctx.Get(dependencyAlias); exists { + if testResult, ok := result.(TestResult); ok { + return pm.extractValueByPath(testResult, path) + } + } + + // Last resort: wait for the dependency + return pm.WaitForDependency(manifestID, dependencyAlias, path) +} + +// ResolveTemplateValue resolves a template value like "{{ users-list.response.body.data[0].id }}" +func (pm *PassManager) ResolveTemplateValue(manifestID, templateStr string) (any, error) { + // Parse template string to extract alias and path + alias, path, err := pm.parseTemplateString(templateStr) + if err != nil { + return nil, err + } + + return pm.GetDependencyValue(manifestID, alias, path) +} + +// parseTemplateString parses "{{ alias.path }}" format +func (pm *PassManager) parseTemplateString(templateStr string) (alias, path string, err error) { + // Remove {{ and }} and trim spaces + content := strings.TrimSpace(templateStr) + if strings.HasPrefix(content, "{{") && strings.HasSuffix(content, "}}") { + content = strings.TrimSpace(content[2 : len(content)-2]) + } + + // Split on first dot + parts := strings.SplitN(content, ".", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid template format: %s", templateStr) + } + + return parts[0], parts[1], nil +} + +// GetSaveRequirement returns save requirement for a manifest +func (pm *PassManager) GetSaveRequirement(manifestID string) (SaveRequirement, bool) { + req, exists := pm.saveRequirements[manifestID] + return req, exists +} + +// ListRequiredSaves returns all manifests that need to save data +func (pm *PassManager) ListRequiredSaves() map[string]SaveRequirement { + result := make(map[string]SaveRequirement) + for id, req := range pm.saveRequirements { + if req.Required { + result[id] = req + } + } + return result +} diff --git a/internal/core/runner/depends/rules.go b/internal/core/runner/depends/rules.go new file mode 100644 index 0000000..fcf68e9 --- /dev/null +++ b/internal/core/runner/depends/rules.go @@ -0,0 +1,248 @@ +package depends + +import ( + "fmt" + "regexp" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" +) + +// DependencyRule defines interface for dependency analysis rules +type DependencyRule interface { + // Name returns the rule name for debugging + Name() string + + // AnalyzeDependencies extracts dependencies from manifest + AnalyzeDependencies(manifest manifests.Manifest) ([]Dependency, error) + + // GetPriority returns priority for this type of dependency + GetPriority() int + + // CanHandle checks if this rule can handle the given manifest + CanHandle(manifest manifests.Manifest) bool +} + +// Dependency represents a dependency relationship +type Dependency struct { + From string // Source manifest ID + To string // Target manifest ID (what we depend on) + Type DependencyType // Type of dependency + Metadata map[string]any // Additional metadata (e.g., what data to save) +} + +type DependencyType string + +const ( + DependencyTypeExplicit DependencyType = "explicit" // From dependsOn field + DependencyTypeTemplate DependencyType = "template" // From template references + DependencyTypeValue DependencyType = "value" // From value passing +) + +// RuleRegistry manages dependency rules +type RuleRegistry struct { + rules []DependencyRule +} + +func NewRuleRegistry() *RuleRegistry { + return &RuleRegistry{ + rules: make([]DependencyRule, 0), + } +} + +func (r *RuleRegistry) Register(rule DependencyRule) { + r.rules = append(r.rules, rule) +} + +func (r *RuleRegistry) GetRules() []DependencyRule { + return r.rules +} + +// ExplicitDependencyRule handles explicit dependsOn declarations +type ExplicitDependencyRule struct{} + +func NewExplicitDependencyRule() *ExplicitDependencyRule { + return &ExplicitDependencyRule{} +} + +func (r *ExplicitDependencyRule) Name() string { + return "explicit" +} + +func (r *ExplicitDependencyRule) CanHandle(manifest manifests.Manifest) bool { + _, ok := manifest.(manifests.Dependencies) + return ok +} + +func (r *ExplicitDependencyRule) AnalyzeDependencies(manifest manifests.Manifest) ([]Dependency, error) { + dep, ok := manifest.(manifests.Dependencies) + if !ok { + return nil, nil + } + + var dependencies []Dependency + fromID := manifest.GetID() + + for _, toID := range dep.GetDependsOn() { + if toID == fromID { + return nil, fmt.Errorf("manifest %s cannot depend on itself", fromID) + } + + dependencies = append(dependencies, Dependency{ + From: fromID, + To: toID, + Type: DependencyTypeExplicit, + }) + } + + return dependencies, nil +} + +func (r *ExplicitDependencyRule) GetPriority() int { + return 100 // Highest priority for explicit dependencies +} + +// TemplateDependencyRule handles template-based dependencies ({{ alias.path }}) +type TemplateDependencyRule struct { + templateRegex *regexp.Regexp +} + +func NewTemplateDependencyRule() *TemplateDependencyRule { + // Regex to match {{ alias.path }} patterns + regex := regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`) + return &TemplateDependencyRule{ + templateRegex: regex, + } +} + +func (r *TemplateDependencyRule) Name() string { + return "template" +} + +func (r *TemplateDependencyRule) CanHandle(_ manifests.Manifest) bool { + // This rule can handle any manifest, we'll check content during analysis + return true +} + +func (r *TemplateDependencyRule) AnalyzeDependencies(manifest manifests.Manifest) ([]Dependency, error) { + var dependencies []Dependency + fromID := manifest.GetID() + + // Extract all template references from the manifest + references := r.extractTemplateReferences(manifest) + + // Group by alias to avoid duplicates and collect required paths + aliasData := make(map[string][]string) + for _, ref := range references { + aliasData[ref.Alias] = append(aliasData[ref.Alias], ref.Path) + } + + // Create dependencies with metadata about what data is needed + for alias, paths := range aliasData { + // Convert alias to full manifest ID (assuming same namespace for now) + // This might need to be more sophisticated based on your ID scheme + toID := r.resolveAliasToID(manifest, alias) + + dependencies = append(dependencies, Dependency{ + From: fromID, + To: toID, + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": paths, + "save_required": true, + }, + }) + } + + return dependencies, nil +} + +func (r *TemplateDependencyRule) GetPriority() int { + return 50 // Medium priority for template dependencies +} + +// TemplateReference represents a parsed template reference +type TemplateReference struct { + Alias string // The alias part (e.g., "users-list") + Path string // The path part (e.g., "response.body.data[0].id") +} + +func (r *TemplateDependencyRule) extractTemplateReferences(manifest manifests.Manifest) []TemplateReference { + var references []TemplateReference + + // Convert manifest to string representation for parsing + // This is a simplified approach - in real implementation you might want + // to traverse the structure more carefully + manifestStr := fmt.Sprintf("%+v", manifest) + + matches := r.templateRegex.FindAllStringSubmatch(manifestStr, -1) + for _, match := range matches { + if len(match) >= 3 { + references = append(references, TemplateReference{ + Alias: match[1], + Path: match[2], + }) + } + } + + return references +} + +func (r *TemplateDependencyRule) resolveAliasToID(manifest manifests.Manifest, alias string) string { + // Simple resolution: assume same namespace and kind as current manifest + // Format: namespace.kind.alias + parts := strings.Split(manifest.GetID(), ".") + if len(parts) >= 2 { + return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], alias) + } + return alias +} + +// KindPriorityRule handles kind-based priorities +type KindPriorityRule struct { + priorities map[string]int +} + +func NewKindPriorityRule() *KindPriorityRule { + return &KindPriorityRule{ + priorities: priorities, + } +} + +func (r *KindPriorityRule) Name() string { + return "kind_priority" +} + +func (r *KindPriorityRule) CanHandle(_ manifests.Manifest) bool { + return true // Can handle any manifest for priority assignment +} + +func (r *KindPriorityRule) AnalyzeDependencies(_ manifests.Manifest) ([]Dependency, error) { + // This rule doesn't create dependencies, just provides priority info + return nil, nil +} + +func (r *KindPriorityRule) GetPriority() int { + return 0 // Lowest priority as this is just for ordering +} + +func (r *KindPriorityRule) GetKindPriority(kind string) int { + if priority, ok := r.priorities[kind]; ok { + return priority + } + return 0 +} + +// DefaultRuleRegistry creates a registry with default rules +func DefaultRuleRegistry() *RuleRegistry { + registry := NewRuleRegistry() + + // Register default rules + registry.Register(NewExplicitDependencyRule()) + registry.Register(NewTemplateDependencyRule()) + registry.Register(NewKindPriorityRule()) + registry.Register(NewHttpTestDependencyRule()) + + return registry +} diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 70de753..6477ce4 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -116,9 +116,9 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c }() url := buildHttpURL(c.Url, man.Spec.Target, c.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) + url = e.passer.Apply(ctx, url) + headers := e.passer.MapHeaders(ctx, c.Headers) + body := e.passer.ApplyBody(ctx, c.Body) if body != nil { if err = json.NewEncoder(reqBody).Encode(body); err != nil { diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 2c1f098..6129447 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -3,13 +3,11 @@ package executor import ( "errors" "fmt" - "github.com/apiqube/cli/internal/report/html" "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/depends" "github.com/apiqube/cli/internal/core/runner/hooks" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -30,17 +28,46 @@ func NewDefaultPlanRunner(registry interfaces.ExecutorRegistry, hooksRunner hook } } -func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { +// V2PlanRunner supports the new dependency system with PassManager +type V2PlanRunner struct { + registry interfaces.ExecutorRegistry + hooksRunner hooks.Runner + passManager *depends.PassManager +} + +func NewV2PlanRunner(registry interfaces.ExecutorRegistry, hooksRunner hooks.Runner, graphResult *depends.GraphResultV2) *V2PlanRunner { + return &V2PlanRunner{ + registry: registry, + hooksRunner: hooksRunner, + passManager: nil, // Will be initialized when context is available + } +} + +func (r *V2PlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { p, ok := manifest.(*plan.Plan) if !ok { return errors.New("invalid manifest type, expected Plan kind") } + // Initialize PassManager if not already done + if r.passManager == nil { + // We need to rebuild the graph result from the plan + // This is a simplified approach - in production you might want to pass the graph result directly + allManifests := ctx.GetAllManifests() + registry := depends.DefaultRuleRegistry() + builder := depends.NewGraphBuilderV2(registry) + graphResult, err := builder.BuildGraphWithRules(allManifests) + if err != nil { + return fmt.Errorf("failed to initialize dependency analysis: %w", err) + } + r.passManager = depends.NewPassManager(ctx, graphResult) + } + var err error output := ctx.GetOutput() planID := p.GetID() - output.Logf(interfaces.InfoLevel, "%s starting plan: %s", planRunnerOutputPrefix, planID) + output.Logf(interfaces.InfoLevel, "%s starting V2 plan: %s", planRunnerOutputPrefix, planID) if err = ctx.Err(); err != nil { output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before start: %v", planRunnerOutputPrefix, err) @@ -72,9 +99,9 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma var execErr error if stage.Parallel { - execErr = r.runManifestsParallel(ctx, stage.Manifests) + execErr = r.runManifestsParallelV2(ctx, stage.Manifests) } else { - execErr = r.runManifestsStrict(ctx, stage.Manifests) + execErr = r.runManifestsStrictV2(ctx, stage.Manifests) } if err = ctx.Err(); err != nil { @@ -134,19 +161,228 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma } } - // TODO: TEMPL CODE HERE !!! - htmlReportGenerator, err := html.NewHTMLReportGenerator() - if err != nil { - fmt.Println("ERROR:", err) - return nil + return nil +} + +func (r *V2PlanRunner) runManifestsStrictV2(ctx interfaces.ExecutionContext, manifestIDs []string) error { + var man manifests.Manifest + var err error + + output := ctx.GetOutput() + + for _, id := range manifestIDs { + if man, err = ctx.GetManifestByID(id); err != nil { + return fmt.Errorf("run %s manifest failed: %s", id, err.Error()) + } + + exec, exists := r.registry.Find(man.GetKind()) + if !exists { + return fmt.Errorf("no executor found for kind: %s", man.GetKind()) + } + + // Check if this manifest should save results + shouldSave := r.passManager.ShouldSaveResult(id) + if shouldSave { + output.Logf(interfaces.InfoLevel, "%s manifest %s will save results for data passing", planRunnerOutputPrefix, id) + } + + 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()) + } + + // Save results if required (this would be integrated with the actual executor) + if shouldSave { + // This is a placeholder - the actual result saving would be done by the executor + // For now, we just log that saving would happen + output.Logf(interfaces.InfoLevel, "%s results saved for manifest %s", planRunnerOutputPrefix, id) + } + + output.Logf(interfaces.InfoLevel, "%s %s manifest finished", planRunnerOutputPrefix, id) } - reporter := report.NewReportService(htmlReportGenerator) - if err = reporter.GenerateReports(ctx); err != nil { - fmt.Println("ERROR:", err) + return nil +} + +func (r *V2PlanRunner) runManifestsParallelV2(ctx interfaces.ExecutionContext, manifestIDs []string) error { + var wg sync.WaitGroup + errChan := make(chan error, len(manifestIDs)) + + output := ctx.GetOutput() + + for _, manId := range manifestIDs { + id := manId + wg.Add(1) + + go func() { + defer wg.Done() + man, err := ctx.GetManifestByID(id) + if err != nil { + errChan <- fmt.Errorf("run %s manifest failed: %s", id, err.Error()) + return + } + + exec, exists := r.registry.Find(man.GetKind()) + if !exists { + errChan <- fmt.Errorf("no executor found for kind: %s", man.GetKind()) + return + } + + // Check if this manifest should save results + shouldSave := r.passManager.ShouldSaveResult(id) + if shouldSave { + output.Logf(interfaces.InfoLevel, "%s manifest %s will save results for data passing", planRunnerOutputPrefix, id) + } + + 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()) + return + } + + // Save results if required + if shouldSave { + output.Logf(interfaces.InfoLevel, "%s results saved for manifest %s", planRunnerOutputPrefix, id) + } + + output.Logf(interfaces.InfoLevel, "%s %s manifest finished", planRunnerOutputPrefix, id) + }() + } + + wg.Wait() + close(errChan) + + var rErr error + + if len(errChan) > 0 { + for err := range errChan { + rErr = errors.Join(rErr, err) + } + + return rErr + } + + return nil +} + +func (r *V2PlanRunner) runHooksWithContext(ctx interfaces.ExecutionContext, event hooks.HookEvent, actions []hooks.Action) error { + if len(actions) == 0 { return nil } - // TODO: END + + select { + case <-ctx.Done(): + return ctx.Err() + default: + return r.hooksRunner.RunHooks(ctx, event, actions) + } +} + +func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { + p, ok := manifest.(*plan.Plan) + if !ok { + return errors.New("invalid manifest type, expected Plan kind") + } + + var err error + output := ctx.GetOutput() + + 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.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.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 + } + } + + var execErr error + if stage.Parallel { + execErr = r.runManifestsParallel(ctx, stage.Manifests) + } else { + 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.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 + } + } + + if execErr != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s failed\nReason: %s", planRunnerOutputPrefix, stageName, execErr.Error()) + + if stage.Hooks != 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.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) + } + } + + return execErr + } + + 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.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.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 + } + } return nil } diff --git a/internal/core/runner/form/directive_executor.go b/internal/core/runner/form/directive_executor.go index 252974f..7102785 100644 --- a/internal/core/runner/form/directive_executor.go +++ b/internal/core/runner/form/directive_executor.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -44,7 +43,7 @@ func (e *defaultDirectiveExecutor) CanHandle(value any) bool { return false } -func (e *defaultDirectiveExecutor) Execute(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { +func (e *defaultDirectiveExecutor) Execute(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) (any, error) { valMap, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("directive executor: expected map input") @@ -57,7 +56,7 @@ func (e *defaultDirectiveExecutor) Execute(ctx interfaces.ExecutionContext, valu // Check dependencies e.checkDependencies(handler, valMap) - return handler.Execute(ctx, e.processor, valMap, pass, processedData, indexStack) + return handler.Execute(ctx, e.processor, valMap, processedData, indexStack) } } } diff --git a/internal/core/runner/form/directives.go b/internal/core/runner/form/directives.go index fab7a78..e65c9e1 100644 --- a/internal/core/runner/form/directives.go +++ b/internal/core/runner/form/directives.go @@ -4,13 +4,12 @@ import ( "fmt" "strconv" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) // DirectiveHandler defines the interface for directive handlers type DirectiveHandler interface { - Execute(ctx interfaces.ExecutionContext, runner Processor, input any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + Execute(ctx interfaces.ExecutionContext, runner Processor, input any, processedData map[string]any, indexStack []int) (any, error) Name() string Dependencies() []string } @@ -36,7 +35,7 @@ func (r *repeatDirective) Dependencies() []string { return r.dependencies } -func (r *repeatDirective) Execute(ctx interfaces.ExecutionContext, processor Processor, input any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { +func (r *repeatDirective) Execute(ctx interfaces.ExecutionContext, processor Processor, input any, processedData map[string]any, indexStack []int) (any, error) { inputMap, ok := input.(map[string]any) if !ok { return nil, fmt.Errorf("__repeat: expected map input") @@ -76,7 +75,7 @@ func (r *repeatDirective) Execute(ctx interfaces.ExecutionContext, processor Pro } for i := 0; i < count; i++ { newStack := append(indexStack[:len(indexStack):len(indexStack)], i) - processed := processor.Process(ctx, tmpl, pass, processedData, newStack) + processed := processor.Process(ctx, tmpl, processedData, newStack) results = append(results, processed) // Если processed β€” map, сразу ΠΏΠΎΠ»ΠΎΠΆΠΈΡ‚ΡŒ Π΅Π³ΠΎ Π² processedData[arrKey][i] if processedData != nil { @@ -98,7 +97,7 @@ func (r *repeatDirective) Execute(ctx interfaces.ExecutionContext, processor Pro for k, v := range m { if submap, is := v.(map[string]any); is { // ΠŸΠ΅Ρ€Π΅ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ Π²Π»ΠΎΠΆΠ΅Π½Π½ΡƒΡŽ map с Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½Ρ‹ΠΌ processedData - m[k] = processor.Process(ctx, submap, pass, mergeProcessedData(processedData, m), []int{i}) + m[k] = processor.Process(ctx, submap, mergeProcessedData(processedData, m), []int{i}) } } arr[i] = m diff --git a/internal/core/runner/form/interfaces.go b/internal/core/runner/form/interfaces.go index 50e55d4..f0e4699 100644 --- a/internal/core/runner/form/interfaces.go +++ b/internal/core/runner/form/interfaces.go @@ -1,29 +1,28 @@ package form import ( - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) // Processor defines the interface for processing different types of values type Processor interface { - Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any + Process(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any } // TemplateResolver defines the interface for resolving templates type TemplateResolver interface { - Resolve(ctx interfaces.ExecutionContext, template string, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + Resolve(ctx interfaces.ExecutionContext, template string, processedData map[string]any, indexStack []int) (any, error) } // DirectiveExecutor defines the interface for executing directives type DirectiveExecutor interface { - Execute(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + Execute(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) (any, error) CanHandle(value any) bool } // ReferenceResolver defines the interface for resolving references type ReferenceResolver interface { - Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, pass []*tests.Pass, indexStack []int) any + Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any } // ValueExtractor defines the interface for extracting values from nested structures diff --git a/internal/core/runner/form/processors.go b/internal/core/runner/form/processors.go index 96013e5..1f5af90 100644 --- a/internal/core/runner/form/processors.go +++ b/internal/core/runner/form/processors.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -20,18 +19,18 @@ func NewStringProcessor(templateResolver TemplateResolver) *StringProcessor { } } -func (p *StringProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { +func (p *StringProcessor) Process(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any { str, ok := value.(string) if !ok { return value } if p.isCompleteTemplate(str) { - result, _ := p.templateResolver.Resolve(ctx, str, pass, processedData, indexStack) + result, _ := p.templateResolver.Resolve(ctx, str, processedData, indexStack) return result } - return p.processMixedString(ctx, str, pass, processedData, indexStack) + return p.processMixedString(ctx, str, processedData, indexStack) } func (p *StringProcessor) isCompleteTemplate(str string) bool { @@ -39,14 +38,14 @@ func (p *StringProcessor) isCompleteTemplate(str string) bool { return strings.HasPrefix(str, "{{") && strings.HasSuffix(str, "}}") && strings.Count(str, "{{") == 1 } -func (p *StringProcessor) processMixedString(ctx interfaces.ExecutionContext, str string, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { +func (p *StringProcessor) processMixedString(ctx interfaces.ExecutionContext, str string, processedData map[string]any, indexStack []int) any { templateRegex := regexp.MustCompile(`\{\{\s*(.*?)\s*}}`) var err error result := templateRegex.ReplaceAllStringFunc(str, func(match string) string { // Extract inner content without brackets inner := strings.TrimSpace(match[2 : len(match)-2]) - processed, e := p.templateResolver.Resolve(ctx, inner, pass, processedData, indexStack) + processed, e := p.templateResolver.Resolve(ctx, inner, processedData, indexStack) if e != nil { err = e return match @@ -73,7 +72,7 @@ func NewMapProcessor(valueProcessor Processor, directiveHandler DirectiveExecuto } } -func (p *MapProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { +func (p *MapProcessor) Process(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any { m, ok := value.(map[string]any) if !ok { return value @@ -81,7 +80,7 @@ func (p *MapProcessor) Process(ctx interfaces.ExecutionContext, value any, pass // Check for directives first if p.directiveHandler.CanHandle(value) { - if result, err := p.directiveHandler.Execute(ctx, value, pass, processedData, indexStack); err == nil { + if result, err := p.directiveHandler.Execute(ctx, value, processedData, indexStack); err == nil { return result } } @@ -90,7 +89,7 @@ func (p *MapProcessor) Process(ctx interfaces.ExecutionContext, value any, pass result := make(map[string]any, len(m)) // Always pass merged processedData (global + current object) for every field for k, v := range m { - result[k] = p.valueProcessor.Process(ctx, v, pass, mergeProcessedData(processedData, result), indexStack) + result[k] = p.valueProcessor.Process(ctx, v, mergeProcessedData(processedData, result), indexStack) } return result } @@ -106,7 +105,7 @@ func NewArrayProcessor(valueProcessor Processor) *ArrayProcessor { } } -func (p *ArrayProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { +func (p *ArrayProcessor) Process(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any { arr, ok := value.([]any) if !ok { return value @@ -114,7 +113,7 @@ func (p *ArrayProcessor) Process(ctx interfaces.ExecutionContext, value any, pas result := make([]any, len(arr)) for i, item := range arr { - result[i] = p.valueProcessor.Process(ctx, item, pass, processedData, indexStack) + result[i] = p.valueProcessor.Process(ctx, item, processedData, indexStack) } return result } @@ -136,14 +135,14 @@ func NewCompositeProcessor(templateResolver TemplateResolver, directiveHandler D return processor } -func (p *CompositeProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { +func (p *CompositeProcessor) Process(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any { switch v := value.(type) { case string: - return p.stringProcessor.Process(ctx, v, pass, processedData, indexStack) + return p.stringProcessor.Process(ctx, v, processedData, indexStack) case map[string]any: - return p.mapProcessor.Process(ctx, v, pass, processedData, indexStack) + return p.mapProcessor.Process(ctx, v, processedData, indexStack) case []any: - return p.arrayProcessor.Process(ctx, v, pass, processedData, indexStack) + return p.arrayProcessor.Process(ctx, v, processedData, indexStack) default: return v } diff --git a/internal/core/runner/form/reference_resolver.go b/internal/core/runner/form/reference_resolver.go index f148207..8d09aa3 100644 --- a/internal/core/runner/form/reference_resolver.go +++ b/internal/core/runner/form/reference_resolver.go @@ -4,7 +4,6 @@ import ( "regexp" "strings" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -19,20 +18,20 @@ func NewDefaultReferenceResolver(templateResolver TemplateResolver) *DefaultRefe } } -func (r *DefaultReferenceResolver) Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, pass []*tests.Pass, indexStack []int) any { +func (r *DefaultReferenceResolver) Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, indexStack []int) any { switch v := value.(type) { case string: - return r.resolveStringReferences(ctx, v, processedData, pass, indexStack) + return r.resolveStringReferences(ctx, v, processedData, indexStack) case map[string]any: - return r.resolveMapReferences(ctx, v, processedData, pass, indexStack) + return r.resolveMapReferences(ctx, v, processedData, indexStack) case []any: - return r.resolveArrayReferences(ctx, v, processedData, pass, indexStack) + return r.resolveArrayReferences(ctx, v, processedData, indexStack) default: return v } } -func (r *DefaultReferenceResolver) resolveStringReferences(ctx interfaces.ExecutionContext, str string, processedData map[string]any, pass []*tests.Pass, indexStack []int) any { +func (r *DefaultReferenceResolver) resolveStringReferences(ctx interfaces.ExecutionContext, str string, processedData map[string]any, indexStack []int) any { if !strings.Contains(str, "{{") { return str } @@ -42,27 +41,27 @@ func (r *DefaultReferenceResolver) resolveStringReferences(ctx interfaces.Execut if len(matches) > 0 { // If we found Body references, resolve the entire string as template - result, _ := r.templateResolver.Resolve(ctx, str, pass, processedData, indexStack) + result, _ := r.templateResolver.Resolve(ctx, str, processedData, indexStack) return result } return str } -func (r *DefaultReferenceResolver) resolveMapReferences(ctx interfaces.ExecutionContext, m map[string]any, processedData map[string]any, pass []*tests.Pass, indexStack []int) map[string]any { +func (r *DefaultReferenceResolver) resolveMapReferences(ctx interfaces.ExecutionContext, m map[string]any, processedData map[string]any, indexStack []int) map[string]any { result := make(map[string]any, len(m)) for k, v := range m { - result[k] = r.Resolve(ctx, v, processedData, pass, indexStack) + result[k] = r.Resolve(ctx, v, processedData, indexStack) } return result } -func (r *DefaultReferenceResolver) resolveArrayReferences(ctx interfaces.ExecutionContext, arr []any, processedData map[string]any, pass []*tests.Pass, indexStack []int) []any { +func (r *DefaultReferenceResolver) resolveArrayReferences(ctx interfaces.ExecutionContext, arr []any, processedData map[string]any, indexStack []int) []any { result := make([]any, len(arr)) for i, item := range arr { // Add current index to stack for nested array processing newIndexStack := append(indexStack, i) - result[i] = r.Resolve(ctx, item, processedData, pass, newIndexStack) + result[i] = r.Resolve(ctx, item, processedData, newIndexStack) } return result } diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go index 2ab81f5..72fbdf4 100644 --- a/internal/core/runner/form/runner.go +++ b/internal/core/runner/form/runner.go @@ -7,7 +7,6 @@ import ( "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" ) @@ -51,15 +50,14 @@ func (r *Runner) RegisterDirective(handler DirectiveHandler) { } // Apply processes a string input with pass mappings and template resolution -func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string, pass []*tests.Pass) string { +func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string) string { result := input - result = r.applyPassMappings(ctx, result, pass) result = r.applyTemplateResolution(ctx, result) return result } // ApplyBody processes a map body with full form processing capabilities -func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, pass []*tests.Pass) map[string]any { +func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any) map[string]any { if body == nil { return nil } @@ -70,7 +68,7 @@ func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, default: } - processed := r.processor.Process(ctx, body, pass, nil, []int{}) + processed := r.processor.Process(ctx, body, nil, []int{}) select { case <-ctx.Done(): return nil @@ -78,7 +76,7 @@ func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, } if processedMap, ok := processed.(map[string]any); ok { - resolved := r.referenceResolver.Resolve(ctx, processedMap, processedMap, pass, []int{}) + resolved := r.referenceResolver.Resolve(ctx, processedMap, processedMap, []int{}) select { case <-ctx.Done(): return nil @@ -96,7 +94,7 @@ func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, } // MapHeaders processes header mappings -func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, pass []*tests.Pass) map[string]string { +func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string) map[string]string { if headers == nil { return nil } @@ -108,8 +106,8 @@ func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string] default: } - processedKey := r.processHeaderValue(ctx, key, pass) - processedValue := r.processHeaderValue(ctx, value, pass) + processedKey := r.processHeaderValue(ctx, key) + processedValue := r.processHeaderValue(ctx, value) result[processedKey] = processedValue } return result @@ -120,28 +118,6 @@ 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) { - if val, ok := ctx.Get(mapKey); ok { - result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", val)) - } - } - } - } - } - return result -} - func (r *Runner) applyTemplateResolution(ctx interfaces.ExecutionContext, input string) string { reg := regexp.MustCompile(`\{\{\s*([^}\s]+)\s*}}`) return reg.ReplaceAllStringFunc(input, func(match string) string { @@ -163,13 +139,13 @@ func (r *Runner) applyTemplateResolution(ctx interfaces.ExecutionContext, input }) } -func (r *Runner) processHeaderValue(ctx interfaces.ExecutionContext, value string, pass []*tests.Pass) string { +func (r *Runner) processHeaderValue(ctx interfaces.ExecutionContext, value string) string { select { case <-ctx.Done(): return value default: } - processed := r.processor.Process(ctx, value, pass, nil, []int{}) + processed := r.processor.Process(ctx, value, nil, []int{}) select { case <-ctx.Done(): return value diff --git a/internal/core/runner/form/template_resolver.go b/internal/core/runner/form/template_resolver.go index a25a243..64e8fd0 100644 --- a/internal/core/runner/form/template_resolver.go +++ b/internal/core/runner/form/template_resolver.go @@ -5,7 +5,6 @@ import ( "strings" "unicode" - "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" ) @@ -23,7 +22,7 @@ func NewDefaultTemplateResolver(templateEngine *templates.TemplateEngine, valueE } } -func (r *DefaultTemplateResolver) Resolve(ctx interfaces.ExecutionContext, templateStr string, _ []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { +func (r *DefaultTemplateResolver) Resolve(ctx interfaces.ExecutionContext, templateStr string, processedData map[string]any, indexStack []int) (any, error) { templateStr = strings.TrimSpace(templateStr) content := templateStr // If input was wrapped in {{ }}, remove them for processing diff --git a/internal/core/runner/plan/manager.go b/internal/core/runner/plan/manager.go index 3e086aa..114eacb 100644 --- a/internal/core/runner/plan/manager.go +++ b/internal/core/runner/plan/manager.go @@ -10,6 +10,7 @@ import ( "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/runner/depends" ) var kindPriority = map[string]int{ @@ -24,6 +25,7 @@ const defaultPriority = 10_000 type Manager interface { Generate() (*plan.Plan, error) + GenerateV2() (*plan.Plan, *depends.GraphResultV2, error) CheckPlan(*plan.Plan) error } @@ -144,6 +146,89 @@ func (g *basicManager) Generate() (*plan.Plan, error) { return &newPlan, nil } +// GenerateV2 generates plan using the new V2 dependency system +func (g *basicManager) GenerateV2() (*plan.Plan, *depends.GraphResultV2, error) { + if len(g.manifests) == 0 { + return nil, nil, fmt.Errorf("manifests not provided for generating the plan") + } + + // Convert map to slice for V2 system + var manifestSlice []manifests.Manifest + for _, m := range g.manifests { + if m.GetKind() != manifests.PlanKind { + manifestSlice = append(manifestSlice, m) + } + } + + // Create rule registry with default rules + registry := depends.DefaultRuleRegistry() + + // Build graph using V2 system + builder := depends.NewGraphBuilderV2(registry) + graphResult, err := builder.BuildGraphWithRules(manifestSlice) + if err != nil { + return nil, nil, fmt.Errorf("failed to build dependency graph: %w", err) + } + + // Convert execution order to stages + stages := g.createStagesFromExecutionOrder(graphResult.ExecutionOrder, g.manifests) + + // Create plan + var newPlan plan.Plan + newPlan.Default() + newPlan.Spec.Stages = stages + + // Generate hash + planData, err := operations.NormalizeYAML(&newPlan) + if err != nil { + return nil, nil, fmt.Errorf("fail while generating plan hash: %v", err) + } + + planHash, err := utils.CalculateContentHash(planData) + if err != nil { + return nil, nil, fmt.Errorf("fail while calculation plan hash: %v", err) + } + + meta := newPlan.GetMeta() + meta.SetHash(planHash) + meta.SetCreatedBy("plan-generator-v2") + + return &newPlan, graphResult, nil +} + +// createStagesFromExecutionOrder creates stages from V2 execution order +func (g *basicManager) createStagesFromExecutionOrder(executionOrder []string, manifests map[string]manifests.Manifest) []plan.Stage { + // Group manifests by kind and dependency level + var stages []plan.Stage + var current []string + var currentKind string + + for _, id := range executionOrder { + m, exists := manifests[id] + if !exists { + continue + } + + kind := m.GetKind() + + // If kind changes, create a new stage + if currentKind != "" && kind != currentKind && len(current) > 0 { + stages = append(stages, makeStage(current, manifests, g.mode, g.stableSort, g.parallel)) + current = []string{} + } + + current = append(current, id) + currentKind = kind + } + + // Add remaining manifests + if len(current) > 0 { + stages = append(stages, makeStage(current, manifests, g.mode, g.stableSort, g.parallel)) + } + + return stages +} + func groupByLayers(sorted []string, mans map[string]manifests.Manifest, mode string, stable, parallel bool) []plan.Stage { sort.SliceStable(sorted, func(i, j int) bool { ki := mans[sorted[i]].GetKind() From d77f700b08bac5aea90355058ff47fdb690dedd7 Mon Sep 17 00:00:00 2001 From: Nofre Date: Fri, 20 Jun 2025 19:24:19 +0200 Subject: [PATCH 13/19] refactor(runner): enhanced dependency graph builder with smart template analysis and improved execution ordering --- internal/core/runner/depends/builder_v2.go | 84 +- internal/core/runner/depends/dependencies.go | 12 +- .../core/runner/depends/graph_builder_v2.go | 719 ++++++++++-------- internal/core/runner/depends/graph_test.go | 60 +- internal/core/runner/depends/rules.go | 8 + 5 files changed, 429 insertions(+), 454 deletions(-) diff --git a/internal/core/runner/depends/builder_v2.go b/internal/core/runner/depends/builder_v2.go index 267e733..285dbc0 100644 --- a/internal/core/runner/depends/builder_v2.go +++ b/internal/core/runner/depends/builder_v2.go @@ -1,55 +1,17 @@ package depends -// GraphBuilderV2 is the new modular graph builder -type GraphBuilderV2 struct { - ruleRegistry *RuleRegistry -} - -func NewGraphBuilderV2(registry *RuleRegistry) *GraphBuilderV2 { - if registry == nil { - registry = DefaultRuleRegistry() - } - return &GraphBuilderV2{ - ruleRegistry: registry, - } -} - -// GraphResultV2 contains enhanced graph information -type GraphResultV2 struct { - Graph map[string][]string // adjacency list (inter-manifest only) - ExecutionOrder []string // topologically sorted order - Dependencies []Dependency // inter-manifest dependencies only - AllDependencies []Dependency // all discovered dependencies (inter + intra) - SaveRequirements map[string]SaveRequirement // what each manifest needs to save - Metadata map[string]map[string]any // additional metadata per manifest - IntraManifestDeps map[string][]Dependency // intra-manifest dependencies grouped by manifest -} - -// SaveRequirement defines what data a manifest should save for others -type SaveRequirement struct { - Required bool // whether saving is required - ManifestID string // ID of the manifest that provides data - RequiredPaths []string // specific paths to save (renamed from Paths for consistency) - Paths []string // alias for RequiredPaths for backward compatibility - UsedBy []string // which manifests will use this data (alias for Consumers) - Consumers []string // which manifests will consume this data -} - -// AddRule adds a new rule to the registry -func (gb *GraphBuilderV2) AddRule(rule DependencyRule) { - gb.ruleRegistry.Register(rule) -} +import "strings" // GetSaveRequirement returns save requirement for a manifest -func (gr *GraphResultV2) GetSaveRequirement(manifestID string) (SaveRequirement, bool) { - req, exists := gr.SaveRequirements[manifestID] +func (r *GraphResultV2) GetSaveRequirement(manifestID string) (SaveRequirement, bool) { + req, exists := r.SaveRequirements[manifestID] return req, exists } // GetDependenciesFor returns all dependencies for a manifest -func (gr *GraphResultV2) GetDependenciesFor(manifestID string) []Dependency { +func (r *GraphResultV2) GetDependenciesFor(manifestID string) []Dependency { var deps []Dependency - for _, dep := range gr.Dependencies { + for _, dep := range r.Dependencies { if dep.From == manifestID { deps = append(deps, dep) } @@ -57,21 +19,10 @@ func (gr *GraphResultV2) GetDependenciesFor(manifestID string) []Dependency { return deps } -// GetDependentsOf returns dependencies that depend on the given manifest -func (gr *GraphResultV2) GetDependentsOf(manifestID string) []Dependency { - var dependents []Dependency - for _, dep := range gr.Dependencies { - if dep.To == manifestID { - dependents = append(dependents, dep) - } - } - return dependents -} - // GetDependenciesOf returns dependencies that the given manifest depends on -func (gr *GraphResultV2) GetDependenciesOf(manifestID string) []Dependency { +func (r *GraphResultV2) GetDependenciesOf(manifestID string) []Dependency { var dependencies []Dependency - for _, dep := range gr.Dependencies { + for _, dep := range r.Dependencies { if dep.From == manifestID { dependencies = append(dependencies, dep) } @@ -80,15 +31,28 @@ func (gr *GraphResultV2) GetDependenciesOf(manifestID string) []Dependency { } // GetIntraManifestDependencies returns intra-manifest dependencies for a given manifest -func (gr *GraphResultV2) GetIntraManifestDependencies(manifestID string) []Dependency { - if deps, exists := gr.IntraManifestDeps[manifestID]; exists { +func (r *GraphResultV2) GetIntraManifestDependencies(manifestID string) []Dependency { + if deps, exists := r.IntraManifestDeps[manifestID]; exists { return deps } return []Dependency{} } // HasIntraManifestDependencies checks if a manifest has intra-manifest dependencies -func (gr *GraphResultV2) HasIntraManifestDependencies(manifestID string) bool { - deps, exists := gr.IntraManifestDeps[manifestID] +func (r *GraphResultV2) HasIntraManifestDependencies(manifestID string) bool { + deps, exists := r.IntraManifestDeps[manifestID] return exists && len(deps) > 0 } + +// GetDependentsOf returns all dependencies that depend on the given manifest +func (r *GraphResultV2) GetDependentsOf(manifestID string) []Dependency { + var dependents []Dependency + + for _, dep := range r.Dependencies { + if dep.To == manifestID || strings.HasPrefix(dep.To, manifestID+"#") { + dependents = append(dependents, dep) + } + } + + return dependents +} diff --git a/internal/core/runner/depends/dependencies.go b/internal/core/runner/depends/dependencies.go index 9c97f37..9c34929 100644 --- a/internal/core/runner/depends/dependencies.go +++ b/internal/core/runner/depends/dependencies.go @@ -10,12 +10,6 @@ import ( "github.com/apiqube/cli/internal/core/manifests" ) -var priorityOrder = map[string]int{ - manifests.ValuesKind: 100, - manifests.ServerKind: 40, - manifests.ServiceKind: 30, -} - type GraphResult struct { Graph map[string][]string ExecutionOrder []string @@ -57,8 +51,10 @@ func BuildGraphWithPriority(mans []manifests.Manifest) (*GraphResult, error) { } } + // Use priority queue for topological sorting with priorities + // Lower priority number = higher execution priority (executes first) priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority > b.Priority + return a.Priority < b.Priority // Lower priority number first }) for id, degree := range inDegree { @@ -101,7 +97,7 @@ func getPriority(kind string) int { if p, ok := priorityOrder[kind]; ok { return p } - return 0 + return 100 // Default low priority for unknown kinds } func findCyclicNodes(inDegree map[string]int) []string { diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index b28dda8..1885570 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -3,460 +3,509 @@ package depends import ( "container/heap" "fmt" - "github.com/apiqube/cli/internal/core/manifests/utils" + "regexp" "strings" "github.com/apiqube/cli/internal/collections" "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" ) -var ( - priorities = map[string]int{ - manifests.ValuesKind: 1, - manifests.ServerKind: 10, - manifests.ServiceKind: 20, - manifests.HttpTestKind: 30, - manifests.HttpLoadTestKind: 40, - } -) +var priorityOrder = map[string]int{ + manifests.ValuesKind: 1, + manifests.ServerKind: 10, + manifests.ServiceKind: 20, + manifests.HttpTestKind: 30, + manifests.HttpLoadTestKind: 40, +} -// BuildGraphWithRules builds a dependency graph using registered rules -func (gb *GraphBuilderV2) BuildGraphWithRules(mans []manifests.Manifest) (*GraphResultV2, error) { - if len(mans) == 0 { - return &GraphResultV2{ - Graph: make(map[string][]string), - ExecutionOrder: []string{}, - Dependencies: []Dependency{}, - AllDependencies: []Dependency{}, - SaveRequirements: make(map[string]SaveRequirement), - Metadata: make(map[string]map[string]any), - IntraManifestDeps: make(map[string][]Dependency), - }, nil - } +// GraphBuilderV2 builds dependency graphs using rule-based analysis +type GraphBuilderV2 struct { + registry *RuleRegistry + manifestPriority map[string]int + templateRegex *regexp.Regexp +} - // Step 1: Analyze dependencies using all rules - allDependencies, err := gb.analyzeDependencies(mans) - if err != nil { - return nil, fmt.Errorf("failed to analyze dependencies: %w", err) - } +// GraphResultV2 represents the result of graph building with enhanced metadata +type GraphResultV2 struct { + Graph map[string][]string // Adjacency list representation + ExecutionOrder []string // Topologically sorted execution order + Dependencies []Dependency // All inter-manifest dependencies + IntraManifestDeps map[string][]Dependency // Dependencies within manifests + SaveRequirements map[string]SaveRequirement // What data needs to be saved + ManifestPriorities map[string]int // Priority of each manifest + AliasToManifest map[string]string // Maps alias to manifest ID + TestCaseAliases map[string]TestCaseAliasInfo // Maps alias to test case info +} - // Step 2: Separate inter-manifest and intra-manifest dependencies - manifestMap := make(map[string]manifests.Manifest) - for _, manifest := range mans { - manifestMap[manifest.GetID()] = manifest - } +// SaveRequirement defines what data needs to be saved from a manifest execution +type SaveRequirement struct { + Required bool // Whether saving is required + RequiredPaths []string // Specific paths that need to be saved + Consumers []string // Which manifests consume this data + Paths []string // All paths (for compatibility) +} - interManifestDeps, intraManifestDeps := gb.separateDependencies(allDependencies, manifestMap) +// TestCaseAliasInfo contains information about test case aliases +type TestCaseAliasInfo struct { + ManifestID string // Full manifest ID (e.g., "default.HttpTest.http-test-users") + Alias string // The alias name (e.g., "fetch-users") + TestCaseIndex int // Index of the test case in the manifest + RequiredPaths []string // Paths that other test cases need from this alias + Consumers []string // Which manifests/test cases consume this alias +} - // Step 3: Build graph from inter-manifest dependencies only - graph := gb.buildAdjacencyGraph(interManifestDeps, mans) +// NewGraphBuilderV2 creates a new graph builder with rule registry +func NewGraphBuilderV2(registry *RuleRegistry) *GraphBuilderV2 { + return &GraphBuilderV2{ + registry: registry, + manifestPriority: make(map[string]int), + templateRegex: regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`), + } +} - // Step 4: Build execution order with topological sort - executionOrder, err := gb.buildExecutionOrder(mans, graph, interManifestDeps) - if err != nil { - return nil, fmt.Errorf("failed to build execution order: %w", err) +// BuildGraphWithRules builds dependency graph using registered rules +func (gb *GraphBuilderV2) BuildGraphWithRules(manifests []manifests.Manifest) (*GraphResultV2, error) { + result := &GraphResultV2{ + Graph: make(map[string][]string), + Dependencies: make([]Dependency, 0), + IntraManifestDeps: make(map[string][]Dependency), + SaveRequirements: make(map[string]SaveRequirement), + ManifestPriorities: make(map[string]int), + AliasToManifest: make(map[string]string), + TestCaseAliases: make(map[string]TestCaseAliasInfo), } - // Step 5: Build save requirements (using all dependencies) - saveRequirements := gb.calculateSaveRequirements(allDependencies, mans) + // Step 1: Initialize manifest priorities and collect aliases + if err := gb.initializeManifests(manifests, result); err != nil { + return nil, err + } - // Step 6: Build metadata - metadata := gb.collectMetadata(allDependencies, mans) + // Step 2: Analyze dependencies using all rules (but ignore explicit dependencies) + allDependencies, err := gb.analyzeAllDependencies(manifests) + if err != nil { + return nil, err + } - // Step 7: Group intra-manifest dependencies by manifest - intraManifestDepsByManifest := gb.groupIntraManifestDeps(intraManifestDeps, manifestMap) + // Step 3: Separate inter-manifest and intra-manifest dependencies + gb.categorizeDependencies(allDependencies, result) - return &GraphResultV2{ - Graph: graph, - ExecutionOrder: executionOrder, - Dependencies: interManifestDeps, - AllDependencies: allDependencies, - SaveRequirements: saveRequirements, - Metadata: metadata, - IntraManifestDeps: intraManifestDepsByManifest, - }, nil -} + // Step 4: Build adjacency graph from dependencies + gb.buildAdjacencyGraph(result) -// separateDependencies separates inter-manifest and intra-manifest dependencies -func (gb *GraphBuilderV2) separateDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) ([]Dependency, []Dependency) { - var interManifestDeps []Dependency - var intraManifestDeps []Dependency + // Step 5: Calculate save requirements + gb.calculateSaveRequirements(result) - for _, dep := range dependencies { - if gb.isIntraManifestDependency(dep, manifestMap) { - // This is an intra-manifest dependency (e.g., test case aliases) - intraManifestDeps = append(intraManifestDeps, dep) - } else { - // This is an inter-manifest dependency - interManifestDeps = append(interManifestDeps, dep) - } + // Step 6: Build execution order using topological sort with priorities + executionOrder, err := gb.buildExecutionOrder(manifests, result.Dependencies) + if err != nil { + return nil, err } + result.ExecutionOrder = executionOrder - return interManifestDeps, intraManifestDeps + return result, nil } -// groupIntraManifestDeps groups intra-manifest dependencies by base manifest ID -func (gb *GraphBuilderV2) groupIntraManifestDeps(intraManifestDeps []Dependency, manifestMap map[string]manifests.Manifest) map[string][]Dependency { - grouped := make(map[string][]Dependency) - - for _, dep := range intraManifestDeps { - baseID := gb.getBaseManifestID(dep.From) - grouped[baseID] = append(grouped[baseID], dep) +// initializeManifests sets up manifest priorities and collects alias information +func (gb *GraphBuilderV2) initializeManifests(manifests []manifests.Manifest, result *GraphResultV2) error { + for _, manifest := range manifests { + manifestID := manifest.GetID() + + // Set priority + priority := gb.getManifestPriority(manifest) + gb.manifestPriority[manifestID] = priority + result.ManifestPriorities[manifestID] = priority + + // Collect test case aliases for HTTP tests + if httpTest, ok := manifest.(*api.Http); ok { + for i, testCase := range httpTest.Spec.Cases { + if testCase.Alias != nil { + alias := *testCase.Alias + result.AliasToManifest[alias] = manifestID + result.TestCaseAliases[alias] = TestCaseAliasInfo{ + ManifestID: manifestID, + Alias: alias, + TestCaseIndex: i, + RequiredPaths: make([]string, 0), + Consumers: make([]string, 0), + } + } + } + } } - return grouped + return nil } -// isIntraManifestDependency checks if a dependency is within the same manifest -func (gb *GraphBuilderV2) isIntraManifestDependency(dep Dependency, manifestMap map[string]manifests.Manifest) bool { - // Extract base manifest ID (without alias/fragment) - fromBase := gb.getBaseManifestID(dep.From) - toBase := gb.getBaseManifestID(dep.To) +// analyzeAllDependencies analyzes dependencies using all registered rules +func (gb *GraphBuilderV2) analyzeAllDependencies(manifests []manifests.Manifest) ([]Dependency, error) { + var allDependencies []Dependency - // If both refer to the same base manifest, it's intra-manifest - return fromBase == toBase -} + for _, manifest := range manifests { + for _, rule := range gb.registry.GetRules() { + if rule.CanHandle(manifest) { + // Skip explicit dependency rule - we want to build dependencies ourselves + if rule.Name() == "explicit" { + continue + } -// getBaseManifestID extracts the base manifest ID without alias/fragment -func (gb *GraphBuilderV2) getBaseManifestID(id string) string { - // Remove fragment part (after #) - if idx := strings.Index(id, "#"); idx != -1 { - return id[:idx] + deps, err := rule.AnalyzeDependencies(manifest) + if err != nil { + return nil, fmt.Errorf("rule %s failed for manifest %s: %w", rule.Name(), manifest.GetID(), err) + } + allDependencies = append(allDependencies, deps...) + } + } } - return id + + // Add smart template-based dependencies + smartDeps, err := gb.analyzeSmartTemplateDependencies(manifests, allDependencies) + if err != nil { + return nil, err + } + allDependencies = append(allDependencies, smartDeps...) + + return allDependencies, nil } -// buildAdjacencyGraph creates adjacency list from dependencies -func (gb *GraphBuilderV2) buildAdjacencyGraph(dependencies []Dependency, manifests []manifests.Manifest) map[string][]string { - graph := make(map[string][]string) +// analyzeSmartTemplateDependencies creates inter-manifest dependencies based on template analysis +func (gb *GraphBuilderV2) analyzeSmartTemplateDependencies(mans []manifests.Manifest, existingDeps []Dependency) ([]Dependency, error) { + var smartDeps []Dependency + aliasToManifest := make(map[string]string) - // Initialize all manifests in the graph - for _, manifest := range manifests { - id := manifest.GetID() - graph[id] = []string{} + // Build alias to manifest mapping + for _, manifest := range mans { + if httpTest, ok := manifest.(*api.Http); ok { + for _, testCase := range httpTest.Spec.Cases { + if testCase.Alias != nil { + aliasToManifest[*testCase.Alias] = manifest.GetID() + } + } + } } - // Add edges from dependencies - for _, dep := range dependencies { - // Only add edges for dependencies where both nodes exist as manifests - fromBase := gb.getBaseManifestID(dep.From) - toBase := gb.getBaseManifestID(dep.To) + // Analyze template references and create inter-manifest dependencies + for _, manifest := range mans { + manifestID := manifest.GetID() - // Check if both base IDs exist in our manifest map - fromExists := false - toExists := false + if httpTest, ok := manifest.(*api.Http); ok { + // Find all template references in this manifest + templateRefs := gb.extractAllTemplateReferences(httpTest) - for _, manifest := range manifests { - if manifest.GetID() == fromBase { - fromExists = true - } - if manifest.GetID() == toBase { - toExists = true + // Group by alias and create dependencies + aliasGroups := make(map[string][]string) + for _, ref := range templateRefs { + aliasGroups[ref.Alias] = append(aliasGroups[ref.Alias], ref.Path) } - } - if fromExists && toExists { - // Add edge: To -> From (dependency direction) - graph[toBase] = append(graph[toBase], fromBase) + for alias, paths := range aliasGroups { + // Check if this alias refers to another manifest + if targetManifestID, exists := aliasToManifest[alias]; exists && targetManifestID != manifestID { + // This is an inter-manifest dependency + smartDeps = append(smartDeps, Dependency{ + From: manifestID, + To: targetManifestID, + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": paths, + "save_required": true, + "smart_detected": true, + }, + }) + } else if alias == "Values" { + // Check if this is a reference to Values manifest + for _, valuesManifest := range mans { + if valuesManifest.GetKind() == manifests.ValuesKind { + smartDeps = append(smartDeps, Dependency{ + From: manifestID, + To: valuesManifest.GetID(), + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": paths, + "save_required": true, + "smart_detected": true, + }, + }) + break + } + } + } + } } } - return graph + return smartDeps, nil } -// buildExecutionOrder creates topologically sorted execution order -func (gb *GraphBuilderV2) buildExecutionOrder(mans []manifests.Manifest, graph map[string][]string, dependencies []Dependency) ([]string, error) { - // Calculate in-degrees - inDegree := make(map[string]int) - idToManifest := make(map[string]manifests.Manifest) - nodePriority := make(map[string]int) - - // Initialize - for _, manifest := range mans { - id := manifest.GetID() - idToManifest[id] = manifest - inDegree[id] = 0 - nodePriority[id] = gb.getManifestPriority(manifest) - } +// extractAllTemplateReferences extracts all template references from an HTTP test +func (gb *GraphBuilderV2) extractAllTemplateReferences(httpTest *api.Http) []TemplateReference { + var references []TemplateReference - // Calculate in-degrees from dependencies - for _, dep := range dependencies { - fromBase := gb.getBaseManifestID(dep.From) - if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { - inDegree[fromBase]++ - } - } + for _, testCase := range httpTest.Spec.Cases { + // Check endpoint + refs := gb.findTemplateReferencesInString(testCase.Endpoint) + references = append(references, refs...) - // Priority queue for topological sort - priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority > b.Priority - }) + // Check URL + refs = gb.findTemplateReferencesInString(testCase.Url) + references = append(references, refs...) - // Add nodes with no dependencies - for id, degree := range inDegree { - if degree == 0 { - heap.Push(priorityQueue, &Node{ - ID: id, - Priority: nodePriority[id], - }) + // Check headers + for _, value := range testCase.Headers { + refs = gb.findTemplateReferencesInString(value) + references = append(references, refs...) } - } - var order []string - for priorityQueue.Len() > 0 { - current := heap.Pop(priorityQueue).(*Node).ID - order = append(order, current) + // Check body recursively + if testCase.Body != nil { + refs = gb.findTemplateReferencesInValue(testCase.Body) + references = append(references, refs...) + } - // Process neighbors - for _, neighbor := range graph[current] { - if inDegree[neighbor] > 0 { - inDegree[neighbor]-- - if inDegree[neighbor] == 0 { - heap.Push(priorityQueue, &Node{ - ID: neighbor, - Priority: nodePriority[neighbor], - }) - } + // Check assertions + for _, assert := range testCase.Assert { + if assert.Template != "" { + refs = gb.findTemplateReferencesInString(assert.Template) + references = append(references, refs...) } } } - // Check for cycles - if len(order) != len(mans) { - cyclicNodes := gb.findCyclicNodes(inDegree) - return nil, fmt.Errorf("cyclic dependency detected: %v", cyclicNodes) - } - - return order, nil + return references } -// getManifestPriorityByID gets priority by manifest ID -func (gb *GraphBuilderV2) getManifestPriorityByID(manifestID string) int { - // Extract kind from ID (assuming format: namespace.kind.name) - _, kind, _ := utils.ParseManifestID(manifestID) - return gb.getKindPriority(kind) -} +// findTemplateReferencesInString finds template references in a string +func (gb *GraphBuilderV2) findTemplateReferencesInString(str string) []TemplateReference { + var references []TemplateReference + matches := gb.templateRegex.FindAllStringSubmatch(str, -1) -// getKindPriority returns priority for a manifest kind -func (gb *GraphBuilderV2) getKindPriority(kind string) int { - if priority, ok := priorities[kind]; ok { - return priority + for _, match := range matches { + if len(match) >= 3 { + references = append(references, TemplateReference{ + Alias: match[1], + Path: match[2], + }) + } } - return 1_000 + return references } -// analyzeDependencies runs all rules to discover dependencies -func (gb *GraphBuilderV2) analyzeDependencies(manifests []manifests.Manifest) ([]Dependency, error) { - var allDependencies []Dependency - - for _, manifest := range manifests { - for _, rule := range gb.ruleRegistry.GetRules() { - if !rule.CanHandle(manifest) { - continue - } +// findTemplateReferencesInValue recursively finds template references in any value +func (gb *GraphBuilderV2) findTemplateReferencesInValue(value any) []TemplateReference { + var references []TemplateReference - dependencies, err := rule.AnalyzeDependencies(manifest) - if err != nil { - return nil, fmt.Errorf("rule %s failed for manifest %s: %w", - rule.Name(), manifest.GetID(), err) - } - - allDependencies = append(allDependencies, dependencies...) + switch v := value.(type) { + case string: + references = append(references, gb.findTemplateReferencesInString(v)...) + case map[string]any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) + } + case []any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) + } + case map[any]any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) } } - return gb.deduplicateDependencies(allDependencies), nil + return references } -// deduplicateDependencies removes duplicate dependencies -func (gb *GraphBuilderV2) deduplicateDependencies(deps []Dependency) []Dependency { - seen := make(map[string]bool) - var result []Dependency +// categorizeDependencies separates inter-manifest and intra-manifest dependencies +func (gb *GraphBuilderV2) categorizeDependencies(allDependencies []Dependency, result *GraphResultV2) { + for _, dep := range allDependencies { + fromManifest := gb.getBaseManifestID(dep.From) + toManifest := gb.getBaseManifestID(dep.To) - for _, dep := range deps { - key := fmt.Sprintf("%s->%s:%s", dep.From, dep.To, dep.Type) - if !seen[key] { - seen[key] = true - result = append(result, dep) + if fromManifest == toManifest { + // Intra-manifest dependency + if result.IntraManifestDeps[fromManifest] == nil { + result.IntraManifestDeps[fromManifest] = make([]Dependency, 0) + } + result.IntraManifestDeps[fromManifest] = append(result.IntraManifestDeps[fromManifest], dep) + } else { + // Inter-manifest dependency + result.Dependencies = append(result.Dependencies, dep) } } - - return result } -// calculateSaveRequirements determines what each manifest needs to save -func (gb *GraphBuilderV2) calculateSaveRequirements(dependencies []Dependency, manifests []manifests.Manifest) map[string]SaveRequirement { - requirements := make(map[string]SaveRequirement) +// buildAdjacencyGraph builds the adjacency graph from dependencies +func (gb *GraphBuilderV2) buildAdjacencyGraph(result *GraphResultV2) { + for _, dep := range result.Dependencies { + toManifest := gb.getBaseManifestID(dep.To) + fromManifest := gb.getBaseManifestID(dep.From) - // Initialize all manifests with no save requirement - for _, manifest := range manifests { - requirements[manifest.GetID()] = SaveRequirement{ - Required: false, - ManifestID: manifest.GetID(), - RequiredPaths: []string{}, - Paths: []string{}, - UsedBy: []string{}, - Consumers: []string{}, + if result.Graph[toManifest] == nil { + result.Graph[toManifest] = make([]string, 0) } + result.Graph[toManifest] = append(result.Graph[toManifest], fromManifest) } +} - // Process template dependencies to determine save requirements - for _, dep := range dependencies { +// calculateSaveRequirements determines what data needs to be saved +func (gb *GraphBuilderV2) calculateSaveRequirements(result *GraphResultV2) { + // Process inter-manifest dependencies + for _, dep := range result.Dependencies { if dep.Type == DependencyTypeTemplate { - toBase := gb.getBaseManifestID(dep.To) - req := requirements[toBase] + toManifest := gb.getBaseManifestID(dep.To) + + // Update save requirement for the target manifest + req := result.SaveRequirements[toManifest] req.Required = true - req.UsedBy = append(req.UsedBy, dep.From) req.Consumers = append(req.Consumers, dep.From) - // Add required paths from metadata if paths, ok := dep.Metadata["required_paths"].([]string); ok { req.RequiredPaths = append(req.RequiredPaths, paths...) - req.Paths = append(req.Paths, paths...) // for backward compatibility + req.Paths = append(req.Paths, paths...) } - requirements[toBase] = req + result.SaveRequirements[toManifest] = req + + // Update test case alias info if applicable + if alias, ok := dep.Metadata["alias"].(string); ok { + if aliasInfo, exists := result.TestCaseAliases[alias]; exists { + aliasInfo.Consumers = append(aliasInfo.Consumers, dep.From) + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + aliasInfo.RequiredPaths = append(aliasInfo.RequiredPaths, paths...) + } + result.TestCaseAliases[alias] = aliasInfo + } + } } } - // Remove duplicates from paths and consumers - for id, req := range requirements { - req.RequiredPaths = gb.removeDuplicateStrings(req.RequiredPaths) - req.Paths = gb.removeDuplicateStrings(req.Paths) - req.UsedBy = gb.removeDuplicateStrings(req.UsedBy) - req.Consumers = gb.removeDuplicateStrings(req.Consumers) - requirements[id] = req - } + // Process intra-manifest dependencies + for manifestID, deps := range result.IntraManifestDeps { + req := result.SaveRequirements[manifestID] + req.Required = true + req.Consumers = append(req.Consumers, manifestID) // Self-consumer - return requirements -} + for _, dep := range deps { + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + req.RequiredPaths = append(req.RequiredPaths, paths...) + req.Paths = append(req.Paths, paths...) + } + } -// collectMetadata gathers metadata from all dependencies -func (gb *GraphBuilderV2) collectMetadata(dependencies []Dependency, manifests []manifests.Manifest) map[string]map[string]any { - metadata := make(map[string]map[string]any) + result.SaveRequirements[manifestID] = req + } +} - // Initialize metadata for all manifests +// buildExecutionOrder creates topologically sorted execution order +func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, dependencies []Dependency) ([]string, error) { + // Initialize in-degree count for each manifest + inDegree := make(map[string]int) for _, manifest := range manifests { - metadata[manifest.GetID()] = make(map[string]any) + inDegree[manifest.GetID()] = 0 } - // Collect metadata from dependencies + // Calculate in-degrees from inter-manifest dependencies + // A manifest has incoming edge if it depends on another manifest for _, dep := range dependencies { fromBase := gb.getBaseManifestID(dep.From) - if dep.Metadata != nil { - if metadata[fromBase] == nil { - metadata[fromBase] = make(map[string]any) - } - manifestMeta := metadata[fromBase] - for key, value := range dep.Metadata { - manifestMeta[key] = value + toBase := gb.getBaseManifestID(dep.To) + + // Only count dependencies between different manifests + // fromBase depends on toBase, so fromBase gets an incoming edge + if fromBase != toBase { + if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { + inDegree[fromBase]++ } } } - return metadata -} + // Use priority queue for topological sorting with priorities + // Lower priority number = higher execution priority (executes first) + priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { + return a.Priority > b.Priority // Lower priority number first + }) -// getManifestPriority calculates priority for a manifest -func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { - // Try to find KindPriorityRule - for _, rule := range gb.ruleRegistry.GetRules() { - if kindRule, ok := rule.(*KindPriorityRule); ok { - return kindRule.GetKindPriority(manifest.GetKind()) + // Add all nodes with zero in-degree to the queue + for manifestID, degree := range inDegree { + if degree == 0 { + priority := gb.manifestPriority[manifestID] + heap.Push(priorityQueue, &Node{ + ID: manifestID, + Priority: priority, + }) } } - // Fallback to direct priority calculation - return gb.getKindPriority(manifest.GetKind()) -} + var executionOrder []string -// findCyclicNodes finds nodes involved in cycles -func (gb *GraphBuilderV2) findCyclicNodes(inDegree map[string]int) []string { - var cyclicNodes []string - for id, degree := range inDegree { - if degree > 0 { - cyclicNodes = append(cyclicNodes, id) - } - } - return cyclicNodes -} + // Process nodes in topological order with priority + for priorityQueue.Len() > 0 { + current := heap.Pop(priorityQueue).(*Node).ID + executionOrder = append(executionOrder, current) -// removeDuplicateStrings removes duplicate strings from a slice -func (gb *GraphBuilderV2) removeDuplicateStrings(slice []string) []string { - keys := make(map[string]bool) - var result []string + // Process dependencies where current is the target (dependency) + // When we execute current, we can reduce in-degree of manifests that depend on it + for _, dep := range dependencies { + fromBase := gb.getBaseManifestID(dep.From) + toBase := gb.getBaseManifestID(dep.To) - for _, item := range slice { - if !keys[item] { - keys[item] = true - result = append(result, item) + // If current is the target (toBase), reduce in-degree of dependent (fromBase) + if toBase == current && fromBase != toBase { + inDegree[fromBase]-- + if inDegree[fromBase] == 0 { + priority := gb.manifestPriority[fromBase] + heap.Push(priorityQueue, &Node{ + ID: fromBase, + Priority: priority, + }) + } + } } } - return result -} - -// Legacy methods for backward compatibility -func (gb *GraphBuilderV2) filterIntraManifestDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) []Dependency { - interManifestDeps, _ := gb.separateDependencies(dependencies, manifestMap) - return interManifestDeps -} - -func (gb *GraphBuilderV2) buildSaveRequirements(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]SaveRequirement { - requirements := make(map[string]SaveRequirement) - - // Group dependencies by target (what provides the data) - providerMap := make(map[string][]Dependency) - for _, dep := range dependencies { - if dep.Type == DependencyTypeTemplate { - providerMap[dep.To] = append(providerMap[dep.To], dep) + // Check for cycles + if len(executionOrder) != len(manifests) { + var remaining []string + for manifestID, degree := range inDegree { + if degree > 0 { + remaining = append(remaining, manifestID) + } } + return nil, fmt.Errorf("cyclic dependency detected among manifests: %v", remaining) } - // Build save requirements - for providerID, deps := range providerMap { - var requiredPaths []string - var consumers []string - - for _, dep := range deps { - consumers = append(consumers, dep.From) + return executionOrder, nil +} - // Extract required paths from metadata - if paths, ok := dep.Metadata["required_paths"].([]string); ok { - requiredPaths = append(requiredPaths, paths...) - } - } +// getManifestPriority returns priority for a manifest based on its kind +func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { + kind := manifest.GetKind() - // Remove duplicates - requiredPaths = gb.removeDuplicateStrings(requiredPaths) - consumers = gb.removeDuplicateStrings(consumers) - - requirements[providerID] = SaveRequirement{ - Required: true, - ManifestID: providerID, - RequiredPaths: requiredPaths, - Paths: requiredPaths, // for backward compatibility - UsedBy: consumers, // for backward compatibility - Consumers: consumers, + // Find kind priority rule + for _, rule := range gb.registry.GetRules() { + if kindRule, ok := rule.(*KindPriorityRule); ok { + return kindRule.GetKindPriority(kind) } } - return requirements + return gb.getManifestPriorityByID(manifest.GetID()) } -func (gb *GraphBuilderV2) buildMetadata(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]map[string]any { - metadata := make(map[string]map[string]any) - - for _, dep := range dependencies { - if metadata[dep.From] == nil { - metadata[dep.From] = make(map[string]any) - } - - // Add dependency metadata - depKey := fmt.Sprintf("dep_%s", dep.To) - metadata[dep.From][depKey] = dep.Metadata +// getManifestPriorityByID returns priority for a manifest by its ID +func (gb *GraphBuilderV2) getManifestPriorityByID(manifestID string) int { + if priority, exists := gb.manifestPriority[manifestID]; exists { + return priority } + return 0 +} - return metadata +// getBaseManifestID extracts base manifest ID from potentially extended ID +func (gb *GraphBuilderV2) getBaseManifestID(id string) string { + // Remove any suffix after # (for test case aliases) + if idx := strings.Index(id, "#"); idx != -1 { + return id[:idx] + } + return id } diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go index df80e73..6f7e177 100644 --- a/internal/core/runner/depends/graph_test.go +++ b/internal/core/runner/depends/graph_test.go @@ -7,7 +7,6 @@ import ( "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "github.com/apiqube/cli/internal/core/manifests/kinds/values" - "github.com/apiqube/cli/internal/core/manifests/utils" "net/http" "strings" "testing" @@ -119,11 +118,6 @@ func TestGraphBuilder(t *testing.T) { }, }, }, - Dependencies: kinds.Dependencies{ - DependsOn: []string{ - utils.FormManifestID(manifests.DefaultNamespace, manifests.ValuesKind, "values-test"), - }, - }, }, } @@ -138,18 +132,19 @@ func TestGraphBuilder(t *testing.T) { // Print results printDependencyGraph(builder, result) - // Assertions - if len(result.Dependencies) != 1 { - t.Errorf("Expected 1 dependency, got %d", len(result.Dependencies)) + expectedOrder := []string{ + mans[0].GetID(), + mans[1].GetID(), } - // Values should come first - if result.ExecutionOrder[0] != mans[0].GetID() { - t.Errorf("Expected Values manifest first, got %s", result.ExecutionOrder[0]) + for i, expected := range expectedOrder { + if result.ExecutionOrder[i] != expected { + t.Errorf("Expected %s at position %d, got %s", expected, i, result.ExecutionOrder[i]) + } } }) - // Test Case 3: Single manifest with intra-manifest dependencies (your case) + // Test Case 3: Single manifest with intra-manifest dependencies t.Run("Single manifest with intra-manifest dependencies", func(t *testing.T) { fmt.Println("\nπŸ“ Test Case 3: Single manifest with intra-manifest dependencies") @@ -295,11 +290,6 @@ func TestGraphBuilder(t *testing.T) { }, }, }, - Dependencies: kinds.Dependencies{ - DependsOn: []string{ - utils.FormManifestID(manifests.DefaultNamespace, manifests.ServerKind, "http-test-server"), - }, - }, }, &api.Http{ BaseManifest: kinds.BaseManifest{ @@ -347,12 +337,6 @@ func TestGraphBuilder(t *testing.T) { }, }, }, - Dependencies: kinds.Dependencies{ - DependsOn: []string{ - utils.FormManifestID(manifests.DefaultNamespace, manifests.ValuesKind, "values-test"), - utils.FormManifestID(manifests.DefaultNamespace, manifests.ServerKind, "http-test-server"), - }, - }, }, } @@ -368,7 +352,7 @@ func TestGraphBuilder(t *testing.T) { printDependencyGraph(builder, result) // Assertions - if len(result.ExecutionOrder) != 3 { + if len(result.ExecutionOrder) != 4 { t.Errorf("Expected 3 manifests in execution order, got %d", len(result.ExecutionOrder)) } @@ -388,32 +372,6 @@ func TestGraphBuilder(t *testing.T) { }) } -/* -// BenchmarkGraphBuilder benchmarks the graph building performance -func BenchmarkGraphBuilder(b *testing.B) { - // Create a large set of manifests for benchmarking - var manifests []manifests.Manifest - - for i := 0; i < 100; i++ { - manifests = append(manifests, &MockManifest{ - ID: fmt.Sprintf("test.HttpTest.test-%d", i), - Kind: manifests.HttpTestKind, - }) - } - - registry := DefaultRuleRegistry() - builder := NewGraphBuilderV2(registry) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := builder.BuildGraphWithRules(manifests) - if err != nil { - b.Fatalf("Failed to build graph: %v", err) - } - } -} -*/ - // PrintDependencyGraph prints a beautiful visualization of the dependency graph func printDependencyGraph(gb *GraphBuilderV2, result *GraphResultV2) { fmt.Println("\n" + strings.Repeat("=", 80)) diff --git a/internal/core/runner/depends/rules.go b/internal/core/runner/depends/rules.go index fcf68e9..4e9b9ef 100644 --- a/internal/core/runner/depends/rules.go +++ b/internal/core/runner/depends/rules.go @@ -204,6 +204,14 @@ type KindPriorityRule struct { priorities map[string]int } +var priorities = map[string]int{ + manifests.ValuesKind: 1, + manifests.ServerKind: 10, + manifests.ServiceKind: 20, + manifests.HttpTestKind: 30, + manifests.HttpLoadTestKind: 40, +} + func NewKindPriorityRule() *KindPriorityRule { return &KindPriorityRule{ priorities: priorities, From 2db3473c287e3a1474e5fd9602260610be31e680 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sat, 21 Jun 2025 20:59:20 +0200 Subject: [PATCH 14/19] refactor(runner): improved dependency graph execution order determinism with consistent sorting --- internal/core/runner/depends/dependencies.go | 33 +++++++++- .../core/runner/depends/graph_builder_v2.go | 62 +++++++++++-------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/internal/core/runner/depends/dependencies.go b/internal/core/runner/depends/dependencies.go index 9c34929..db4f683 100644 --- a/internal/core/runner/depends/dependencies.go +++ b/internal/core/runner/depends/dependencies.go @@ -3,6 +3,7 @@ package depends import ( "container/heap" "fmt" + "sort" "strings" "github.com/apiqube/cli/internal/collections" @@ -26,6 +27,7 @@ func BuildGraphWithPriority(mans []manifests.Manifest) (*GraphResult, error) { idToNode := make(map[string]manifests.Manifest) nodePriority := make(map[string]int) + // Initialize all manifests for _, node := range mans { id := node.GetID() idToNode[id] = node @@ -38,6 +40,7 @@ func BuildGraphWithPriority(mans []manifests.Manifest) (*GraphResult, error) { } } + // Build dependency graph for _, man := range mans { if dep, has := man.(manifests.Dependencies); has { id := man.GetID() @@ -54,24 +57,48 @@ func BuildGraphWithPriority(mans []manifests.Manifest) (*GraphResult, error) { // Use priority queue for topological sorting with priorities // Lower priority number = higher execution priority (executes first) priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority < b.Priority // Lower priority number first + // First compare by priority (lower number = higher priority) + if a.Priority != b.Priority { + return a.Priority < b.Priority + } + // If priorities are equal, sort by ID for deterministic behavior + return a.ID < b.ID }) + // Add all nodes with zero in-degree to the queue + var zeroInDegreeNodes []*Node for id, degree := range inDegree { if degree == 0 { - heap.Push(priorityQueue, &Node{ + zeroInDegreeNodes = append(zeroInDegreeNodes, &Node{ ID: id, Priority: nodePriority[id], }) } } + // Sort for deterministic behavior + sort.Slice(zeroInDegreeNodes, func(i, j int) bool { + if zeroInDegreeNodes[i].Priority != zeroInDegreeNodes[j].Priority { + return zeroInDegreeNodes[i].Priority < zeroInDegreeNodes[j].Priority + } + return zeroInDegreeNodes[i].ID < zeroInDegreeNodes[j].ID + }) + + // Add to priority queue + for _, node := range zeroInDegreeNodes { + heap.Push(priorityQueue, node) + } + var order []string for priorityQueue.Len() > 0 { current := heap.Pop(priorityQueue).(*Node).ID order = append(order, current) - for _, neighbor := range graph[current] { + // Process neighbors in sorted order for deterministic behavior + neighbors := graph[current] + sort.Strings(neighbors) + + for _, neighbor := range neighbors { inDegree[neighbor]-- if inDegree[neighbor] == 0 { heap.Push(priorityQueue, &Node{ diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index 1885570..dc534fb 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -4,6 +4,7 @@ import ( "container/heap" "fmt" "regexp" + "sort" "strings" "github.com/apiqube/cli/internal/collections" @@ -402,18 +403,17 @@ func (gb *GraphBuilderV2) calculateSaveRequirements(result *GraphResultV2) { func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, dependencies []Dependency) ([]string, error) { // Initialize in-degree count for each manifest inDegree := make(map[string]int) + manifestIDs := make([]string, 0, len(manifests)) for _, manifest := range manifests { - inDegree[manifest.GetID()] = 0 + id := manifest.GetID() + inDegree[id] = 0 + manifestIDs = append(manifestIDs, id) } - // Calculate in-degrees from inter-manifest dependencies - // A manifest has incoming edge if it depends on another manifest + // Calculate in-degrees from all inter-manifest dependencies for _, dep := range dependencies { fromBase := gb.getBaseManifestID(dep.From) toBase := gb.getBaseManifestID(dep.To) - - // Only count dependencies between different manifests - // fromBase depends on toBase, so fromBase gets an incoming edge if fromBase != toBase { if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { inDegree[fromBase]++ @@ -421,48 +421,60 @@ func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, de } } - // Use priority queue for topological sorting with priorities - // Lower priority number = higher execution priority (executes first) + // Use priority queue for topological sorting with priorities and deterministic order priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority > b.Priority // Lower priority number first + return a.Priority > b.Priority }) - // Add all nodes with zero in-degree to the queue - for manifestID, degree := range inDegree { + // Collect all nodes with zero in-degree + zeroInDegreeNodes := make([]*Node, 0) + for id, degree := range inDegree { if degree == 0 { - priority := gb.manifestPriority[manifestID] - heap.Push(priorityQueue, &Node{ - ID: manifestID, - Priority: priority, + zeroInDegreeNodes = append(zeroInDegreeNodes, &Node{ + ID: id, + Priority: gb.manifestPriority[id], }) } } - var executionOrder []string + // Sort for deterministic behavior + sort.Slice(zeroInDegreeNodes, func(i, j int) bool { + if zeroInDegreeNodes[i].Priority != zeroInDegreeNodes[j].Priority { + return zeroInDegreeNodes[i].Priority < zeroInDegreeNodes[j].Priority + } + return zeroInDegreeNodes[i].ID < zeroInDegreeNodes[j].ID + }) + + executionOrder := make([]string, 0, len(manifests)) - // Process nodes in topological order with priority for priorityQueue.Len() > 0 { - current := heap.Pop(priorityQueue).(*Node).ID + current := priorityQueue.Pop().(*Node).ID executionOrder = append(executionOrder, current) - // Process dependencies where current is the target (dependency) - // When we execute current, we can reduce in-degree of manifests that depend on it + newNodes := make([]*Node, 0) for _, dep := range dependencies { fromBase := gb.getBaseManifestID(dep.From) toBase := gb.getBaseManifestID(dep.To) - - // If current is the target (toBase), reduce in-degree of dependent (fromBase) if toBase == current && fromBase != toBase { inDegree[fromBase]-- if inDegree[fromBase] == 0 { - priority := gb.manifestPriority[fromBase] - heap.Push(priorityQueue, &Node{ + newNodes = append(newNodes, &Node{ ID: fromBase, - Priority: priority, + Priority: gb.manifestPriority[fromBase], }) } } } + // Π‘ΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ΠΎΠ² ΠΏΠΎ ΠΏΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚Ρƒ ΠΈ ID для Π΄Π΅Ρ‚Π΅Ρ€ΠΌΠΈΠ½ΠΈΠ·ΠΌΠ° + sort.Slice(newNodes, func(i, j int) bool { + if newNodes[i].Priority != newNodes[j].Priority { + return newNodes[i].Priority < newNodes[j].Priority + } + return newNodes[i].ID < newNodes[j].ID + }) + for _, node := range newNodes { + heap.Push(priorityQueue, node) + } } // Check for cycles From 56cf8a9939871acde7166029675c90aa065cb062 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 22 Jun 2025 22:59:00 +0200 Subject: [PATCH 15/19] refactor(runner): replaced priority queue with simple slice queue for deterministic execution ordering --- cmd/cli/run/run.go | 2 +- examples/combined/combined.yaml | 2 +- examples/complex-http-tests/http_test.yaml | 5 ++-- .../core/runner/depends/graph_builder_v2.go | 26 +++++++------------ 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go index 0051d69..27b9391 100644 --- a/cmd/cli/run/run.go +++ b/cmd/cli/run/run.go @@ -69,7 +69,7 @@ var Cmd = &cobra.Command{ cli.Info("Dependency analysis completed:") for manifestID, req := range graphResult.SaveRequirements { if req.Required { - cli.Infof(" %s will save data for: %v", manifestID, req.UsedBy) + cli.Infof(" %s will save data for", manifestID) } } } diff --git a/examples/combined/combined.yaml b/examples/combined/combined.yaml index 4f5ae27..801d567 100644 --- a/examples/combined/combined.yaml +++ b/examples/combined/combined.yaml @@ -57,7 +57,7 @@ spec: Authorization: some_jwt_token type: some_data body: - email: example_email + email: "{{ Values.simple-save.users.username.0 }}" password: example_password username: example_username expected: diff --git a/examples/complex-http-tests/http_test.yaml b/examples/complex-http-tests/http_test.yaml index 788ac36..edfd28e 100644 --- a/examples/complex-http-tests/http_test.yaml +++ b/examples/complex-http-tests/http_test.yaml @@ -7,7 +7,6 @@ metadata: spec: target: http://127.0.0.1:8081 cases: - - name: Fetch User From Server alias: fetch-user method: GET @@ -21,6 +20,6 @@ spec: endpoint: /users assert: - target: status - equals: 200 + equals: 201 body: - user: "{{ fetch-user.response.body }}" \ No newline at end of file + user: "{{ fetch-user.response.body.user }}" \ No newline at end of file diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index dc534fb..046599b 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -1,13 +1,11 @@ package depends import ( - "container/heap" "fmt" "regexp" "sort" "strings" - "github.com/apiqube/cli/internal/collections" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" ) @@ -370,10 +368,11 @@ func (gb *GraphBuilderV2) calculateSaveRequirements(result *GraphResultV2) { result.SaveRequirements[toManifest] = req // Update test case alias info if applicable + var paths []string if alias, ok := dep.Metadata["alias"].(string); ok { if aliasInfo, exists := result.TestCaseAliases[alias]; exists { aliasInfo.Consumers = append(aliasInfo.Consumers, dep.From) - if paths, ok := dep.Metadata["required_paths"].([]string); ok { + if paths, ok = dep.Metadata["required_paths"].([]string); ok { aliasInfo.RequiredPaths = append(aliasInfo.RequiredPaths, paths...) } result.TestCaseAliases[alias] = aliasInfo @@ -421,12 +420,7 @@ func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, de } } - // Use priority queue for topological sorting with priorities and deterministic order - priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority > b.Priority - }) - - // Collect all nodes with zero in-degree + // Use a slice as a queue for topological sorting with priorities and deterministic order zeroInDegreeNodes := make([]*Node, 0) for id, degree := range inDegree { if degree == 0 { @@ -446,16 +440,18 @@ func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, de }) executionOrder := make([]string, 0, len(manifests)) + queue := zeroInDegreeNodes - for priorityQueue.Len() > 0 { - current := priorityQueue.Pop().(*Node).ID - executionOrder = append(executionOrder, current) + for len(queue) > 0 { + currentNode := queue[0] + queue = queue[1:] + executionOrder = append(executionOrder, currentNode.ID) newNodes := make([]*Node, 0) for _, dep := range dependencies { fromBase := gb.getBaseManifestID(dep.From) toBase := gb.getBaseManifestID(dep.To) - if toBase == current && fromBase != toBase { + if toBase == currentNode.ID && fromBase != toBase { inDegree[fromBase]-- if inDegree[fromBase] == 0 { newNodes = append(newNodes, &Node{ @@ -472,9 +468,7 @@ func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, de } return newNodes[i].ID < newNodes[j].ID }) - for _, node := range newNodes { - heap.Push(priorityQueue, node) - } + queue = append(queue, newNodes...) } // Check for cycles From 7432eaf67ce37a531a0a9e1ffa4778ac382dc8e6 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 22 Jun 2025 22:59:00 +0200 Subject: [PATCH 16/19] refactor(runner): enhanced dependency graph builder with improved template analysis and deterministic execution ordering --- internal/core/runner/depends/builder_v2.go | 37 +- .../core/runner/depends/graph_builder_v2.go | 729 ++++++++++-------- internal/core/runner/depends/graph_test.go | 216 ++++++ 3 files changed, 609 insertions(+), 373 deletions(-) diff --git a/internal/core/runner/depends/builder_v2.go b/internal/core/runner/depends/builder_v2.go index 267e733..646dd90 100644 --- a/internal/core/runner/depends/builder_v2.go +++ b/internal/core/runner/depends/builder_v2.go @@ -1,43 +1,8 @@ package depends -// GraphBuilderV2 is the new modular graph builder -type GraphBuilderV2 struct { - ruleRegistry *RuleRegistry -} - -func NewGraphBuilderV2(registry *RuleRegistry) *GraphBuilderV2 { - if registry == nil { - registry = DefaultRuleRegistry() - } - return &GraphBuilderV2{ - ruleRegistry: registry, - } -} - -// GraphResultV2 contains enhanced graph information -type GraphResultV2 struct { - Graph map[string][]string // adjacency list (inter-manifest only) - ExecutionOrder []string // topologically sorted order - Dependencies []Dependency // inter-manifest dependencies only - AllDependencies []Dependency // all discovered dependencies (inter + intra) - SaveRequirements map[string]SaveRequirement // what each manifest needs to save - Metadata map[string]map[string]any // additional metadata per manifest - IntraManifestDeps map[string][]Dependency // intra-manifest dependencies grouped by manifest -} - -// SaveRequirement defines what data a manifest should save for others -type SaveRequirement struct { - Required bool // whether saving is required - ManifestID string // ID of the manifest that provides data - RequiredPaths []string // specific paths to save (renamed from Paths for consistency) - Paths []string // alias for RequiredPaths for backward compatibility - UsedBy []string // which manifests will use this data (alias for Consumers) - Consumers []string // which manifests will consume this data -} - // AddRule adds a new rule to the registry func (gb *GraphBuilderV2) AddRule(rule DependencyRule) { - gb.ruleRegistry.Register(rule) + gb.registry.Register(rule) } // GetSaveRequirement returns save requirement for a manifest diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index b28dda8..046599b 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -1,462 +1,517 @@ package depends import ( - "container/heap" "fmt" - "github.com/apiqube/cli/internal/core/manifests/utils" + "regexp" + "sort" "strings" - "github.com/apiqube/cli/internal/collections" "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" ) -var ( - priorities = map[string]int{ - manifests.ValuesKind: 1, - manifests.ServerKind: 10, - manifests.ServiceKind: 20, - manifests.HttpTestKind: 30, - manifests.HttpLoadTestKind: 40, - } -) +var priorityOrder = map[string]int{ + manifests.ValuesKind: 1, + manifests.ServerKind: 10, + manifests.ServiceKind: 20, + manifests.HttpTestKind: 30, + manifests.HttpLoadTestKind: 40, +} -// BuildGraphWithRules builds a dependency graph using registered rules -func (gb *GraphBuilderV2) BuildGraphWithRules(mans []manifests.Manifest) (*GraphResultV2, error) { - if len(mans) == 0 { - return &GraphResultV2{ - Graph: make(map[string][]string), - ExecutionOrder: []string{}, - Dependencies: []Dependency{}, - AllDependencies: []Dependency{}, - SaveRequirements: make(map[string]SaveRequirement), - Metadata: make(map[string]map[string]any), - IntraManifestDeps: make(map[string][]Dependency), - }, nil - } +// GraphBuilderV2 builds dependency graphs using rule-based analysis +type GraphBuilderV2 struct { + registry *RuleRegistry + manifestPriority map[string]int + templateRegex *regexp.Regexp +} - // Step 1: Analyze dependencies using all rules - allDependencies, err := gb.analyzeDependencies(mans) - if err != nil { - return nil, fmt.Errorf("failed to analyze dependencies: %w", err) - } +// GraphResultV2 represents the result of graph building with enhanced metadata +type GraphResultV2 struct { + Graph map[string][]string // Adjacency list representation + ExecutionOrder []string // Topologically sorted execution order + Dependencies []Dependency // All inter-manifest dependencies + IntraManifestDeps map[string][]Dependency // Dependencies within manifests + SaveRequirements map[string]SaveRequirement // What data needs to be saved + ManifestPriorities map[string]int // Priority of each manifest + AliasToManifest map[string]string // Maps alias to manifest ID + TestCaseAliases map[string]TestCaseAliasInfo // Maps alias to test case info +} - // Step 2: Separate inter-manifest and intra-manifest dependencies - manifestMap := make(map[string]manifests.Manifest) - for _, manifest := range mans { - manifestMap[manifest.GetID()] = manifest - } +// SaveRequirement defines what data needs to be saved from a manifest execution +type SaveRequirement struct { + Required bool // Whether saving is required + RequiredPaths []string // Specific paths that need to be saved + Consumers []string // Which manifests consume this data + Paths []string // All paths (for compatibility) +} - interManifestDeps, intraManifestDeps := gb.separateDependencies(allDependencies, manifestMap) +// TestCaseAliasInfo contains information about test case aliases +type TestCaseAliasInfo struct { + ManifestID string // Full manifest ID (e.g., "default.HttpTest.http-test-users") + Alias string // The alias name (e.g., "fetch-users") + TestCaseIndex int // Index of the test case in the manifest + RequiredPaths []string // Paths that other test cases need from this alias + Consumers []string // Which manifests/test cases consume this alias +} - // Step 3: Build graph from inter-manifest dependencies only - graph := gb.buildAdjacencyGraph(interManifestDeps, mans) +// NewGraphBuilderV2 creates a new graph builder with rule registry +func NewGraphBuilderV2(registry *RuleRegistry) *GraphBuilderV2 { + return &GraphBuilderV2{ + registry: registry, + manifestPriority: make(map[string]int), + templateRegex: regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`), + } +} - // Step 4: Build execution order with topological sort - executionOrder, err := gb.buildExecutionOrder(mans, graph, interManifestDeps) - if err != nil { - return nil, fmt.Errorf("failed to build execution order: %w", err) +// BuildGraphWithRules builds dependency graph using registered rules +func (gb *GraphBuilderV2) BuildGraphWithRules(manifests []manifests.Manifest) (*GraphResultV2, error) { + result := &GraphResultV2{ + Graph: make(map[string][]string), + Dependencies: make([]Dependency, 0), + IntraManifestDeps: make(map[string][]Dependency), + SaveRequirements: make(map[string]SaveRequirement), + ManifestPriorities: make(map[string]int), + AliasToManifest: make(map[string]string), + TestCaseAliases: make(map[string]TestCaseAliasInfo), } - // Step 5: Build save requirements (using all dependencies) - saveRequirements := gb.calculateSaveRequirements(allDependencies, mans) + // Step 1: Initialize manifest priorities and collect aliases + if err := gb.initializeManifests(manifests, result); err != nil { + return nil, err + } - // Step 6: Build metadata - metadata := gb.collectMetadata(allDependencies, mans) + // Step 2: Analyze dependencies using all rules (but ignore explicit dependencies) + allDependencies, err := gb.analyzeAllDependencies(manifests) + if err != nil { + return nil, err + } - // Step 7: Group intra-manifest dependencies by manifest - intraManifestDepsByManifest := gb.groupIntraManifestDeps(intraManifestDeps, manifestMap) + // Step 3: Separate inter-manifest and intra-manifest dependencies + gb.categorizeDependencies(allDependencies, result) - return &GraphResultV2{ - Graph: graph, - ExecutionOrder: executionOrder, - Dependencies: interManifestDeps, - AllDependencies: allDependencies, - SaveRequirements: saveRequirements, - Metadata: metadata, - IntraManifestDeps: intraManifestDepsByManifest, - }, nil -} + // Step 4: Build adjacency graph from dependencies + gb.buildAdjacencyGraph(result) -// separateDependencies separates inter-manifest and intra-manifest dependencies -func (gb *GraphBuilderV2) separateDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) ([]Dependency, []Dependency) { - var interManifestDeps []Dependency - var intraManifestDeps []Dependency + // Step 5: Calculate save requirements + gb.calculateSaveRequirements(result) - for _, dep := range dependencies { - if gb.isIntraManifestDependency(dep, manifestMap) { - // This is an intra-manifest dependency (e.g., test case aliases) - intraManifestDeps = append(intraManifestDeps, dep) - } else { - // This is an inter-manifest dependency - interManifestDeps = append(interManifestDeps, dep) - } + // Step 6: Build execution order using topological sort with priorities + executionOrder, err := gb.buildExecutionOrder(manifests, result.Dependencies) + if err != nil { + return nil, err } + result.ExecutionOrder = executionOrder - return interManifestDeps, intraManifestDeps + return result, nil } -// groupIntraManifestDeps groups intra-manifest dependencies by base manifest ID -func (gb *GraphBuilderV2) groupIntraManifestDeps(intraManifestDeps []Dependency, manifestMap map[string]manifests.Manifest) map[string][]Dependency { - grouped := make(map[string][]Dependency) - - for _, dep := range intraManifestDeps { - baseID := gb.getBaseManifestID(dep.From) - grouped[baseID] = append(grouped[baseID], dep) +// initializeManifests sets up manifest priorities and collects alias information +func (gb *GraphBuilderV2) initializeManifests(manifests []manifests.Manifest, result *GraphResultV2) error { + for _, manifest := range manifests { + manifestID := manifest.GetID() + + // Set priority + priority := gb.getManifestPriority(manifest) + gb.manifestPriority[manifestID] = priority + result.ManifestPriorities[manifestID] = priority + + // Collect test case aliases for HTTP tests + if httpTest, ok := manifest.(*api.Http); ok { + for i, testCase := range httpTest.Spec.Cases { + if testCase.Alias != nil { + alias := *testCase.Alias + result.AliasToManifest[alias] = manifestID + result.TestCaseAliases[alias] = TestCaseAliasInfo{ + ManifestID: manifestID, + Alias: alias, + TestCaseIndex: i, + RequiredPaths: make([]string, 0), + Consumers: make([]string, 0), + } + } + } + } } - return grouped + return nil } -// isIntraManifestDependency checks if a dependency is within the same manifest -func (gb *GraphBuilderV2) isIntraManifestDependency(dep Dependency, manifestMap map[string]manifests.Manifest) bool { - // Extract base manifest ID (without alias/fragment) - fromBase := gb.getBaseManifestID(dep.From) - toBase := gb.getBaseManifestID(dep.To) +// analyzeAllDependencies analyzes dependencies using all registered rules +func (gb *GraphBuilderV2) analyzeAllDependencies(manifests []manifests.Manifest) ([]Dependency, error) { + var allDependencies []Dependency - // If both refer to the same base manifest, it's intra-manifest - return fromBase == toBase -} + for _, manifest := range manifests { + for _, rule := range gb.registry.GetRules() { + if rule.CanHandle(manifest) { + // Skip explicit dependency rule - we want to build dependencies ourselves + if rule.Name() == "explicit" { + continue + } -// getBaseManifestID extracts the base manifest ID without alias/fragment -func (gb *GraphBuilderV2) getBaseManifestID(id string) string { - // Remove fragment part (after #) - if idx := strings.Index(id, "#"); idx != -1 { - return id[:idx] + deps, err := rule.AnalyzeDependencies(manifest) + if err != nil { + return nil, fmt.Errorf("rule %s failed for manifest %s: %w", rule.Name(), manifest.GetID(), err) + } + allDependencies = append(allDependencies, deps...) + } + } } - return id + + // Add smart template-based dependencies + smartDeps, err := gb.analyzeSmartTemplateDependencies(manifests, allDependencies) + if err != nil { + return nil, err + } + allDependencies = append(allDependencies, smartDeps...) + + return allDependencies, nil } -// buildAdjacencyGraph creates adjacency list from dependencies -func (gb *GraphBuilderV2) buildAdjacencyGraph(dependencies []Dependency, manifests []manifests.Manifest) map[string][]string { - graph := make(map[string][]string) +// analyzeSmartTemplateDependencies creates inter-manifest dependencies based on template analysis +func (gb *GraphBuilderV2) analyzeSmartTemplateDependencies(mans []manifests.Manifest, existingDeps []Dependency) ([]Dependency, error) { + var smartDeps []Dependency + aliasToManifest := make(map[string]string) - // Initialize all manifests in the graph - for _, manifest := range manifests { - id := manifest.GetID() - graph[id] = []string{} + // Build alias to manifest mapping + for _, manifest := range mans { + if httpTest, ok := manifest.(*api.Http); ok { + for _, testCase := range httpTest.Spec.Cases { + if testCase.Alias != nil { + aliasToManifest[*testCase.Alias] = manifest.GetID() + } + } + } } - // Add edges from dependencies - for _, dep := range dependencies { - // Only add edges for dependencies where both nodes exist as manifests - fromBase := gb.getBaseManifestID(dep.From) - toBase := gb.getBaseManifestID(dep.To) + // Analyze template references and create inter-manifest dependencies + for _, manifest := range mans { + manifestID := manifest.GetID() - // Check if both base IDs exist in our manifest map - fromExists := false - toExists := false + if httpTest, ok := manifest.(*api.Http); ok { + // Find all template references in this manifest + templateRefs := gb.extractAllTemplateReferences(httpTest) - for _, manifest := range manifests { - if manifest.GetID() == fromBase { - fromExists = true + // Group by alias and create dependencies + aliasGroups := make(map[string][]string) + for _, ref := range templateRefs { + aliasGroups[ref.Alias] = append(aliasGroups[ref.Alias], ref.Path) } - if manifest.GetID() == toBase { - toExists = true - } - } - if fromExists && toExists { - // Add edge: To -> From (dependency direction) - graph[toBase] = append(graph[toBase], fromBase) + for alias, paths := range aliasGroups { + // Check if this alias refers to another manifest + if targetManifestID, exists := aliasToManifest[alias]; exists && targetManifestID != manifestID { + // This is an inter-manifest dependency + smartDeps = append(smartDeps, Dependency{ + From: manifestID, + To: targetManifestID, + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": paths, + "save_required": true, + "smart_detected": true, + }, + }) + } else if alias == "Values" { + // Check if this is a reference to Values manifest + for _, valuesManifest := range mans { + if valuesManifest.GetKind() == manifests.ValuesKind { + smartDeps = append(smartDeps, Dependency{ + From: manifestID, + To: valuesManifest.GetID(), + Type: DependencyTypeTemplate, + Metadata: map[string]any{ + "alias": alias, + "required_paths": paths, + "save_required": true, + "smart_detected": true, + }, + }) + break + } + } + } + } } } - return graph + return smartDeps, nil } -// buildExecutionOrder creates topologically sorted execution order -func (gb *GraphBuilderV2) buildExecutionOrder(mans []manifests.Manifest, graph map[string][]string, dependencies []Dependency) ([]string, error) { - // Calculate in-degrees - inDegree := make(map[string]int) - idToManifest := make(map[string]manifests.Manifest) - nodePriority := make(map[string]int) +// extractAllTemplateReferences extracts all template references from an HTTP test +func (gb *GraphBuilderV2) extractAllTemplateReferences(httpTest *api.Http) []TemplateReference { + var references []TemplateReference - // Initialize - for _, manifest := range mans { - id := manifest.GetID() - idToManifest[id] = manifest - inDegree[id] = 0 - nodePriority[id] = gb.getManifestPriority(manifest) - } + for _, testCase := range httpTest.Spec.Cases { + // Check endpoint + refs := gb.findTemplateReferencesInString(testCase.Endpoint) + references = append(references, refs...) - // Calculate in-degrees from dependencies - for _, dep := range dependencies { - fromBase := gb.getBaseManifestID(dep.From) - if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { - inDegree[fromBase]++ - } - } + // Check URL + refs = gb.findTemplateReferencesInString(testCase.Url) + references = append(references, refs...) - // Priority queue for topological sort - priorityQueue := collections.NewPriorityQueue[*Node](func(a, b *Node) bool { - return a.Priority > b.Priority - }) + // Check headers + for _, value := range testCase.Headers { + refs = gb.findTemplateReferencesInString(value) + references = append(references, refs...) + } - // Add nodes with no dependencies - for id, degree := range inDegree { - if degree == 0 { - heap.Push(priorityQueue, &Node{ - ID: id, - Priority: nodePriority[id], - }) + // Check body recursively + if testCase.Body != nil { + refs = gb.findTemplateReferencesInValue(testCase.Body) + references = append(references, refs...) } - } - var order []string - for priorityQueue.Len() > 0 { - current := heap.Pop(priorityQueue).(*Node).ID - order = append(order, current) - - // Process neighbors - for _, neighbor := range graph[current] { - if inDegree[neighbor] > 0 { - inDegree[neighbor]-- - if inDegree[neighbor] == 0 { - heap.Push(priorityQueue, &Node{ - ID: neighbor, - Priority: nodePriority[neighbor], - }) - } + // Check assertions + for _, assert := range testCase.Assert { + if assert.Template != "" { + refs = gb.findTemplateReferencesInString(assert.Template) + references = append(references, refs...) } } } - // Check for cycles - if len(order) != len(mans) { - cyclicNodes := gb.findCyclicNodes(inDegree) - return nil, fmt.Errorf("cyclic dependency detected: %v", cyclicNodes) - } - - return order, nil + return references } -// getManifestPriorityByID gets priority by manifest ID -func (gb *GraphBuilderV2) getManifestPriorityByID(manifestID string) int { - // Extract kind from ID (assuming format: namespace.kind.name) - _, kind, _ := utils.ParseManifestID(manifestID) - return gb.getKindPriority(kind) -} +// findTemplateReferencesInString finds template references in a string +func (gb *GraphBuilderV2) findTemplateReferencesInString(str string) []TemplateReference { + var references []TemplateReference + matches := gb.templateRegex.FindAllStringSubmatch(str, -1) -// getKindPriority returns priority for a manifest kind -func (gb *GraphBuilderV2) getKindPriority(kind string) int { - if priority, ok := priorities[kind]; ok { - return priority + for _, match := range matches { + if len(match) >= 3 { + references = append(references, TemplateReference{ + Alias: match[1], + Path: match[2], + }) + } } - return 1_000 + return references } -// analyzeDependencies runs all rules to discover dependencies -func (gb *GraphBuilderV2) analyzeDependencies(manifests []manifests.Manifest) ([]Dependency, error) { - var allDependencies []Dependency - - for _, manifest := range manifests { - for _, rule := range gb.ruleRegistry.GetRules() { - if !rule.CanHandle(manifest) { - continue - } +// findTemplateReferencesInValue recursively finds template references in any value +func (gb *GraphBuilderV2) findTemplateReferencesInValue(value any) []TemplateReference { + var references []TemplateReference - dependencies, err := rule.AnalyzeDependencies(manifest) - if err != nil { - return nil, fmt.Errorf("rule %s failed for manifest %s: %w", - rule.Name(), manifest.GetID(), err) - } - - allDependencies = append(allDependencies, dependencies...) + switch v := value.(type) { + case string: + references = append(references, gb.findTemplateReferencesInString(v)...) + case map[string]any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) + } + case []any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) + } + case map[any]any: + for _, val := range v { + references = append(references, gb.findTemplateReferencesInValue(val)...) } } - return gb.deduplicateDependencies(allDependencies), nil + return references } -// deduplicateDependencies removes duplicate dependencies -func (gb *GraphBuilderV2) deduplicateDependencies(deps []Dependency) []Dependency { - seen := make(map[string]bool) - var result []Dependency +// categorizeDependencies separates inter-manifest and intra-manifest dependencies +func (gb *GraphBuilderV2) categorizeDependencies(allDependencies []Dependency, result *GraphResultV2) { + for _, dep := range allDependencies { + fromManifest := gb.getBaseManifestID(dep.From) + toManifest := gb.getBaseManifestID(dep.To) - for _, dep := range deps { - key := fmt.Sprintf("%s->%s:%s", dep.From, dep.To, dep.Type) - if !seen[key] { - seen[key] = true - result = append(result, dep) + if fromManifest == toManifest { + // Intra-manifest dependency + if result.IntraManifestDeps[fromManifest] == nil { + result.IntraManifestDeps[fromManifest] = make([]Dependency, 0) + } + result.IntraManifestDeps[fromManifest] = append(result.IntraManifestDeps[fromManifest], dep) + } else { + // Inter-manifest dependency + result.Dependencies = append(result.Dependencies, dep) } } - - return result } -// calculateSaveRequirements determines what each manifest needs to save -func (gb *GraphBuilderV2) calculateSaveRequirements(dependencies []Dependency, manifests []manifests.Manifest) map[string]SaveRequirement { - requirements := make(map[string]SaveRequirement) +// buildAdjacencyGraph builds the adjacency graph from dependencies +func (gb *GraphBuilderV2) buildAdjacencyGraph(result *GraphResultV2) { + for _, dep := range result.Dependencies { + toManifest := gb.getBaseManifestID(dep.To) + fromManifest := gb.getBaseManifestID(dep.From) - // Initialize all manifests with no save requirement - for _, manifest := range manifests { - requirements[manifest.GetID()] = SaveRequirement{ - Required: false, - ManifestID: manifest.GetID(), - RequiredPaths: []string{}, - Paths: []string{}, - UsedBy: []string{}, - Consumers: []string{}, + if result.Graph[toManifest] == nil { + result.Graph[toManifest] = make([]string, 0) } + result.Graph[toManifest] = append(result.Graph[toManifest], fromManifest) } +} - // Process template dependencies to determine save requirements - for _, dep := range dependencies { +// calculateSaveRequirements determines what data needs to be saved +func (gb *GraphBuilderV2) calculateSaveRequirements(result *GraphResultV2) { + // Process inter-manifest dependencies + for _, dep := range result.Dependencies { if dep.Type == DependencyTypeTemplate { - toBase := gb.getBaseManifestID(dep.To) - req := requirements[toBase] + toManifest := gb.getBaseManifestID(dep.To) + + // Update save requirement for the target manifest + req := result.SaveRequirements[toManifest] req.Required = true - req.UsedBy = append(req.UsedBy, dep.From) req.Consumers = append(req.Consumers, dep.From) - // Add required paths from metadata if paths, ok := dep.Metadata["required_paths"].([]string); ok { req.RequiredPaths = append(req.RequiredPaths, paths...) - req.Paths = append(req.Paths, paths...) // for backward compatibility + req.Paths = append(req.Paths, paths...) } - requirements[toBase] = req + result.SaveRequirements[toManifest] = req + + // Update test case alias info if applicable + var paths []string + if alias, ok := dep.Metadata["alias"].(string); ok { + if aliasInfo, exists := result.TestCaseAliases[alias]; exists { + aliasInfo.Consumers = append(aliasInfo.Consumers, dep.From) + if paths, ok = dep.Metadata["required_paths"].([]string); ok { + aliasInfo.RequiredPaths = append(aliasInfo.RequiredPaths, paths...) + } + result.TestCaseAliases[alias] = aliasInfo + } + } } } - // Remove duplicates from paths and consumers - for id, req := range requirements { - req.RequiredPaths = gb.removeDuplicateStrings(req.RequiredPaths) - req.Paths = gb.removeDuplicateStrings(req.Paths) - req.UsedBy = gb.removeDuplicateStrings(req.UsedBy) - req.Consumers = gb.removeDuplicateStrings(req.Consumers) - requirements[id] = req - } + // Process intra-manifest dependencies + for manifestID, deps := range result.IntraManifestDeps { + req := result.SaveRequirements[manifestID] + req.Required = true + req.Consumers = append(req.Consumers, manifestID) // Self-consumer - return requirements -} + for _, dep := range deps { + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + req.RequiredPaths = append(req.RequiredPaths, paths...) + req.Paths = append(req.Paths, paths...) + } + } -// collectMetadata gathers metadata from all dependencies -func (gb *GraphBuilderV2) collectMetadata(dependencies []Dependency, manifests []manifests.Manifest) map[string]map[string]any { - metadata := make(map[string]map[string]any) + result.SaveRequirements[manifestID] = req + } +} - // Initialize metadata for all manifests +// buildExecutionOrder creates topologically sorted execution order +func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, dependencies []Dependency) ([]string, error) { + // Initialize in-degree count for each manifest + inDegree := make(map[string]int) + manifestIDs := make([]string, 0, len(manifests)) for _, manifest := range manifests { - metadata[manifest.GetID()] = make(map[string]any) + id := manifest.GetID() + inDegree[id] = 0 + manifestIDs = append(manifestIDs, id) } - // Collect metadata from dependencies + // Calculate in-degrees from all inter-manifest dependencies for _, dep := range dependencies { fromBase := gb.getBaseManifestID(dep.From) - if dep.Metadata != nil { - if metadata[fromBase] == nil { - metadata[fromBase] = make(map[string]any) - } - manifestMeta := metadata[fromBase] - for key, value := range dep.Metadata { - manifestMeta[key] = value + toBase := gb.getBaseManifestID(dep.To) + if fromBase != toBase { + if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { + inDegree[fromBase]++ } } } - return metadata -} - -// getManifestPriority calculates priority for a manifest -func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { - // Try to find KindPriorityRule - for _, rule := range gb.ruleRegistry.GetRules() { - if kindRule, ok := rule.(*KindPriorityRule); ok { - return kindRule.GetKindPriority(manifest.GetKind()) - } - } - - // Fallback to direct priority calculation - return gb.getKindPriority(manifest.GetKind()) -} - -// findCyclicNodes finds nodes involved in cycles -func (gb *GraphBuilderV2) findCyclicNodes(inDegree map[string]int) []string { - var cyclicNodes []string + // Use a slice as a queue for topological sorting with priorities and deterministic order + zeroInDegreeNodes := make([]*Node, 0) for id, degree := range inDegree { - if degree > 0 { - cyclicNodes = append(cyclicNodes, id) + if degree == 0 { + zeroInDegreeNodes = append(zeroInDegreeNodes, &Node{ + ID: id, + Priority: gb.manifestPriority[id], + }) } } - return cyclicNodes -} - -// removeDuplicateStrings removes duplicate strings from a slice -func (gb *GraphBuilderV2) removeDuplicateStrings(slice []string) []string { - keys := make(map[string]bool) - var result []string - for _, item := range slice { - if !keys[item] { - keys[item] = true - result = append(result, item) + // Sort for deterministic behavior + sort.Slice(zeroInDegreeNodes, func(i, j int) bool { + if zeroInDegreeNodes[i].Priority != zeroInDegreeNodes[j].Priority { + return zeroInDegreeNodes[i].Priority < zeroInDegreeNodes[j].Priority } - } - - return result -} + return zeroInDegreeNodes[i].ID < zeroInDegreeNodes[j].ID + }) -// Legacy methods for backward compatibility -func (gb *GraphBuilderV2) filterIntraManifestDependencies(dependencies []Dependency, manifestMap map[string]manifests.Manifest) []Dependency { - interManifestDeps, _ := gb.separateDependencies(dependencies, manifestMap) - return interManifestDeps -} + executionOrder := make([]string, 0, len(manifests)) + queue := zeroInDegreeNodes -func (gb *GraphBuilderV2) buildSaveRequirements(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]SaveRequirement { - requirements := make(map[string]SaveRequirement) + for len(queue) > 0 { + currentNode := queue[0] + queue = queue[1:] + executionOrder = append(executionOrder, currentNode.ID) - // Group dependencies by target (what provides the data) - providerMap := make(map[string][]Dependency) - for _, dep := range dependencies { - if dep.Type == DependencyTypeTemplate { - providerMap[dep.To] = append(providerMap[dep.To], dep) + newNodes := make([]*Node, 0) + for _, dep := range dependencies { + fromBase := gb.getBaseManifestID(dep.From) + toBase := gb.getBaseManifestID(dep.To) + if toBase == currentNode.ID && fromBase != toBase { + inDegree[fromBase]-- + if inDegree[fromBase] == 0 { + newNodes = append(newNodes, &Node{ + ID: fromBase, + Priority: gb.manifestPriority[fromBase], + }) + } + } } + // Π‘ΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ΠΎΠ² ΠΏΠΎ ΠΏΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚Ρƒ ΠΈ ID для Π΄Π΅Ρ‚Π΅Ρ€ΠΌΠΈΠ½ΠΈΠ·ΠΌΠ° + sort.Slice(newNodes, func(i, j int) bool { + if newNodes[i].Priority != newNodes[j].Priority { + return newNodes[i].Priority < newNodes[j].Priority + } + return newNodes[i].ID < newNodes[j].ID + }) + queue = append(queue, newNodes...) } - // Build save requirements - for providerID, deps := range providerMap { - var requiredPaths []string - var consumers []string - - for _, dep := range deps { - consumers = append(consumers, dep.From) - - // Extract required paths from metadata - if paths, ok := dep.Metadata["required_paths"].([]string); ok { - requiredPaths = append(requiredPaths, paths...) + // Check for cycles + if len(executionOrder) != len(manifests) { + var remaining []string + for manifestID, degree := range inDegree { + if degree > 0 { + remaining = append(remaining, manifestID) } } - - // Remove duplicates - requiredPaths = gb.removeDuplicateStrings(requiredPaths) - consumers = gb.removeDuplicateStrings(consumers) - - requirements[providerID] = SaveRequirement{ - Required: true, - ManifestID: providerID, - RequiredPaths: requiredPaths, - Paths: requiredPaths, // for backward compatibility - UsedBy: consumers, // for backward compatibility - Consumers: consumers, - } + return nil, fmt.Errorf("cyclic dependency detected among manifests: %v", remaining) } - return requirements + return executionOrder, nil } -func (gb *GraphBuilderV2) buildMetadata(dependencies []Dependency, manifestMap map[string]manifests.Manifest) map[string]map[string]any { - metadata := make(map[string]map[string]any) +// getManifestPriority returns priority for a manifest based on its kind +func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { + kind := manifest.GetKind() - for _, dep := range dependencies { - if metadata[dep.From] == nil { - metadata[dep.From] = make(map[string]any) + // Find kind priority rule + for _, rule := range gb.registry.GetRules() { + if kindRule, ok := rule.(*KindPriorityRule); ok { + return kindRule.GetKindPriority(kind) } + } - // Add dependency metadata - depKey := fmt.Sprintf("dep_%s", dep.To) - metadata[dep.From][depKey] = dep.Metadata + return gb.getManifestPriorityByID(manifest.GetID()) +} + +// getManifestPriorityByID returns priority for a manifest by its ID +func (gb *GraphBuilderV2) getManifestPriorityByID(manifestID string) int { + if priority, exists := gb.manifestPriority[manifestID]; exists { + return priority } + return 0 +} - return metadata +// getBaseManifestID extracts base manifest ID from potentially extended ID +func (gb *GraphBuilderV2) getBaseManifestID(id string) string { + // Remove any suffix after # (for test case aliases) + if idx := strings.Index(id, "#"); idx != -1 { + return id[:idx] + } + return id } diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go index 6f7e177..a8afdf3 100644 --- a/internal/core/runner/depends/graph_test.go +++ b/internal/core/runner/depends/graph_test.go @@ -7,6 +7,7 @@ import ( "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "github.com/apiqube/cli/internal/core/manifests/kinds/values" + "github.com/apiqube/cli/internal/core/manifests/utils" "net/http" "strings" "testing" @@ -370,6 +371,221 @@ func TestGraphBuilder(t *testing.T) { } } }) + + // Test Case 5: Multiple manifests with hard dependencies + t.Run("Multiple manifests with mixed dependencies", func(t *testing.T) { + fmt.Println("\nπŸ“ Test Case 4: Multiple manifests with mixed dependencies") + + mans := []manifests.Manifest{ + &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values-test-1", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` + }{ + Data: map[string]any{ + "data_1": 1, + "data_2": []int{1, 2, 3}, + "user": struct { + Name string + Email string + }{ + Name: "user_1", + Email: "user_1@example.com", + }, + }, + }, + }, + &values.Values{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ValuesKind, + Metadata: kinds.Metadata{ + Name: "values-test-2", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` + }{ + Data: map[string]any{ + "number": 1, + }, + }, + }, + &servers.Server{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.ServerKind, + Metadata: kinds.Metadata{ + Name: "http-test-server", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + BaseURL string `yaml:"baseUrl" json:"baseUrl" validate:"required,url"` + Health string `yaml:"health" json:"health" validate:"omitempty,max=100"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers" validate:"omitempty,max=20"` + }{ + BaseURL: "http://127.0.0.1:8080", + Health: "", + Headers: map[string]string{ + "Content-Type": "application/json", + }}, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test-roles", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case", + Alias: stringPtr("users-roles"), + Method: http.MethodGet, + Endpoint: "/roles", + }, + }, + }, + }, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test-users", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "Simple HTTP Test Case", + Alias: stringPtr("fetch-users"), + Method: http.MethodGet, + Endpoint: "/users", + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Internal Dependencies", + Method: http.MethodDelete, + Endpoint: "/users/{{ fetch-users.response.body.users.0.id }}", + Body: map[string]any{ + "name": "{{ fetch-users.response.body.users.0.name }}", + }, + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Explicit Values Dependencies", + Method: http.MethodPost, + Endpoint: "/users", + Body: map[string]any{ + "user": "{{ Values.values-test-1.user }}", + "role": "{{ users.roles.roles.3 }}", + }, + }, + }, + }, + }, + }, + &api.Http{ + BaseManifest: kinds.BaseManifest{ + Version: "v1", + Kind: manifests.HttpTestKind, + Metadata: kinds.Metadata{ + Name: "http-test-cars", + Namespace: manifests.DefaultNamespace, + }, + }, + Spec: struct { + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []api.HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + }{ + Target: "http://127.0.0.1:8080", + Cases: []api.HttpCase{ + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case", + Alias: stringPtr("fetch-cars"), + Method: http.MethodGet, + Endpoint: "/cars", + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Internal And External Dependencies", + Method: http.MethodDelete, + Endpoint: "/users/{{ fetch-users.response.body.users.0.id }}", + Body: map[string]any{ + "name": "{{ fetch-users.response.body.users.0.name }}", + "number": "{{ Values.values-test-2.number }}", + }, + }, + }, + { + HttpCase: tests.HttpCase{ + Name: "HTTP Test Case With Explicit Values Dependencies", + Method: http.MethodPost, + Endpoint: "/users", + Body: map[string]any{ + "user": "{{ Values.values-test-1.user }}", + "role": "{{ users.roles.roles.3 }}", + }, + }, + }, + }, + }, + }, + } + + registry := DefaultRuleRegistry() + builder := NewGraphBuilderV2(registry) + + result, err := builder.BuildGraphWithRules(mans) + if err != nil { + t.Fatalf("Failed to build graph: %v", err) + } + + // Print results + printDependencyGraph(builder, result) + + // Assertions + if len(result.ExecutionOrder) != len(mans) { + t.Errorf("Expected %d manifests in execution order, got %d", len(mans), len(result.ExecutionOrder)) + } + + _, kind, _ := utils.ParseManifestID(mans[0].GetID()) + var prev = priorities[kind] + for i := 1; i < len(result.ExecutionOrder); i++ { + if _, kind, _ = utils.ParseManifestID(result.ExecutionOrder[i]); prev > priorities[kind] { + t.Errorf("Expected %s to have lower priority than %s (%d), got %d", result.ExecutionOrder[i], result.ExecutionOrder[i-1], prev, priorities[kind]) + } + } + }) } // PrintDependencyGraph prints a beautiful visualization of the dependency graph From 54dd1d315c6b0c5167982b25f6563a8d21792311 Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 23 Jun 2025 01:35:45 +0200 Subject: [PATCH 17/19] refactor(runner): enhanced dependency graph builder with improved template analysis and deterministic execution ordering --- .../core/runner/depends/graph_builder_v2.go | 2 +- internal/core/runner/depends/graph_test.go | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index 046599b..0addc89 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -168,7 +168,7 @@ func (gb *GraphBuilderV2) analyzeAllDependencies(manifests []manifests.Manifest) } // analyzeSmartTemplateDependencies creates inter-manifest dependencies based on template analysis -func (gb *GraphBuilderV2) analyzeSmartTemplateDependencies(mans []manifests.Manifest, existingDeps []Dependency) ([]Dependency, error) { +func (gb *GraphBuilderV2) analyzeSmartTemplateDependencies(mans []manifests.Manifest, _ []Dependency) ([]Dependency, error) { var smartDeps []Dependency aliasToManifest := make(map[string]string) diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go index a8afdf3..f89e192 100644 --- a/internal/core/runner/depends/graph_test.go +++ b/internal/core/runner/depends/graph_test.go @@ -505,7 +505,7 @@ func TestGraphBuilder(t *testing.T) { Endpoint: "/users", Body: map[string]any{ "user": "{{ Values.values-test-1.user }}", - "role": "{{ users.roles.roles.3 }}", + "role": "{{ users-roles.roles.3 }}", }, }, }, @@ -527,22 +527,14 @@ func TestGraphBuilder(t *testing.T) { }{ Target: "http://127.0.0.1:8080", Cases: []api.HttpCase{ - { - HttpCase: tests.HttpCase{ - Name: "HTTP Test Case", - Alias: stringPtr("fetch-cars"), - Method: http.MethodGet, - Endpoint: "/cars", - }, - }, { HttpCase: tests.HttpCase{ Name: "HTTP Test Case With Internal And External Dependencies", Method: http.MethodDelete, Endpoint: "/users/{{ fetch-users.response.body.users.0.id }}", Body: map[string]any{ - "name": "{{ fetch-users.response.body.users.0.name }}", - "number": "{{ Values.values-test-2.number }}", + "name": "{{ fetch-users.response.body.users.0.name }}", + "role": "{{ users-roles.roles.3 }}", }, }, }, @@ -552,8 +544,8 @@ func TestGraphBuilder(t *testing.T) { Method: http.MethodPost, Endpoint: "/users", Body: map[string]any{ - "user": "{{ Values.values-test-1.user }}", - "role": "{{ users.roles.roles.3 }}", + "user": "{{ Values.values-test-1.user }}", + "number": "{{ Values.values-test-2.number }}", }, }, }, From 640eb28d05d46785a14c5e0c90571e8a1d271afa Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 23 Jun 2025 01:47:09 +0200 Subject: [PATCH 18/19] test(runner): improved test case descriptions and removed redundant data in dependency graph tests --- internal/core/runner/depends/graph_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go index f89e192..c141133 100644 --- a/internal/core/runner/depends/graph_test.go +++ b/internal/core/runner/depends/graph_test.go @@ -373,8 +373,8 @@ func TestGraphBuilder(t *testing.T) { }) // Test Case 5: Multiple manifests with hard dependencies - t.Run("Multiple manifests with mixed dependencies", func(t *testing.T) { - fmt.Println("\nπŸ“ Test Case 4: Multiple manifests with mixed dependencies") + t.Run("Multiple manifests with hard dependencies", func(t *testing.T) { + fmt.Println("\nπŸ“ Test Case 5: Multiple manifests with hard dependencies") mans := []manifests.Manifest{ &values.Values{ @@ -390,8 +390,6 @@ func TestGraphBuilder(t *testing.T) { Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` }{ Data: map[string]any{ - "data_1": 1, - "data_2": []int{1, 2, 3}, "user": struct { Name string Email string @@ -456,7 +454,7 @@ func TestGraphBuilder(t *testing.T) { Cases: []api.HttpCase{ { HttpCase: tests.HttpCase{ - Name: "Simple HTTP Test Case", + Name: "Fetch all user's roles from API", Alias: stringPtr("users-roles"), Method: http.MethodGet, Endpoint: "/roles", From 517bf3d4932bc6f452c76fe6e6b8e7942cc0b66f Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 23 Jun 2025 01:54:52 +0200 Subject: [PATCH 19/19] refactor(runner): reorganized imports and removed unused variables for improved code quality --- internal/core/runner/depends/graph_builder_v2.go | 2 -- internal/core/runner/depends/graph_test.go | 15 +++++++++------ internal/core/runner/depends/http_rules.go | 10 +++++----- internal/core/runner/form/runner_test.go | 6 +++--- internal/report/html/html.go | 11 ++++++----- internal/validate/validator_test.go | 6 ------ 6 files changed, 23 insertions(+), 27 deletions(-) diff --git a/internal/core/runner/depends/graph_builder_v2.go b/internal/core/runner/depends/graph_builder_v2.go index 0addc89..88fc206 100644 --- a/internal/core/runner/depends/graph_builder_v2.go +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -402,11 +402,9 @@ func (gb *GraphBuilderV2) calculateSaveRequirements(result *GraphResultV2) { func (gb *GraphBuilderV2) buildExecutionOrder(manifests []manifests.Manifest, dependencies []Dependency) ([]string, error) { // Initialize in-degree count for each manifest inDegree := make(map[string]int) - manifestIDs := make([]string, 0, len(manifests)) for _, manifest := range manifests { id := manifest.GetID() inDegree[id] = 0 - manifestIDs = append(manifestIDs, id) } // Calculate in-degrees from all inter-manifest dependencies diff --git a/internal/core/runner/depends/graph_test.go b/internal/core/runner/depends/graph_test.go index c141133..40d0a66 100644 --- a/internal/core/runner/depends/graph_test.go +++ b/internal/core/runner/depends/graph_test.go @@ -2,15 +2,16 @@ package depends import ( "fmt" + "net/http" + "strings" + "testing" + "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/kinds/servers" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "github.com/apiqube/cli/internal/core/manifests/kinds/values" "github.com/apiqube/cli/internal/core/manifests/utils" - "net/http" - "strings" - "testing" "github.com/apiqube/cli/internal/core/manifests" ) @@ -264,7 +265,8 @@ func TestGraphBuilder(t *testing.T) { Health: "", Headers: map[string]string{ "Content-Type": "application/json", - }}, + }, + }, }, &api.Http{ BaseManifest: kinds.BaseManifest{ @@ -435,7 +437,8 @@ func TestGraphBuilder(t *testing.T) { Health: "", Headers: map[string]string{ "Content-Type": "application/json", - }}, + }, + }, }, &api.Http{ BaseManifest: kinds.BaseManifest{ @@ -569,7 +572,7 @@ func TestGraphBuilder(t *testing.T) { } _, kind, _ := utils.ParseManifestID(mans[0].GetID()) - var prev = priorities[kind] + prev := priorities[kind] for i := 1; i < len(result.ExecutionOrder); i++ { if _, kind, _ = utils.ParseManifestID(result.ExecutionOrder[i]); prev > priorities[kind] { t.Errorf("Expected %s to have lower priority than %s (%d), got %d", result.ExecutionOrder[i], result.ExecutionOrder[i-1], prev, priorities[kind]) diff --git a/internal/core/runner/depends/http_rules.go b/internal/core/runner/depends/http_rules.go index 212e677..6921c21 100644 --- a/internal/core/runner/depends/http_rules.go +++ b/internal/core/runner/depends/http_rules.go @@ -2,10 +2,11 @@ package depends import ( "fmt" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" "reflect" "regexp" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" ) // HttpTestDependencyRule handles HTTP test specific dependencies @@ -285,6 +286,7 @@ func (r *IntraManifestDependencyRule) findIntraManifestReferences(testCase api.H // findReferencesInStruct recursively finds template references in struct fields func (r *IntraManifestDependencyRule) findReferencesInStruct(v reflect.Value, path string, references *[]HttpTemplateReference) { templateRegex := regexp.MustCompile(`\{\{\s*([a-zA-Z][a-zA-Z0-9_-]*)\.(.*?)\s*}}`) + var newPath string switch v.Kind() { case reflect.String: @@ -302,7 +304,6 @@ func (r *IntraManifestDependencyRule) findReferencesInStruct(v reflect.Value, pa case reflect.Map: for _, key := range v.MapKeys() { keyStr := fmt.Sprintf("%v", key.Interface()) - newPath := path if path != "" { newPath = fmt.Sprintf("%s.%s", path, keyStr) } else { @@ -312,7 +313,7 @@ func (r *IntraManifestDependencyRule) findReferencesInStruct(v reflect.Value, pa } case reflect.Slice, reflect.Array: for i := 0; i < v.Len(); i++ { - newPath := fmt.Sprintf("%s[%d]", path, i) + newPath = fmt.Sprintf("%s[%d]", path, i) r.findReferencesInStruct(v.Index(i), newPath, references) } case reflect.Struct: @@ -320,7 +321,6 @@ func (r *IntraManifestDependencyRule) findReferencesInStruct(v reflect.Value, pa for i := 0; i < v.NumField(); i++ { field := t.Field(i) if field.IsExported() { - newPath := path if path != "" { newPath = fmt.Sprintf("%s.%s", path, field.Name) } else { diff --git a/internal/core/runner/form/runner_test.go b/internal/core/runner/form/runner_test.go index 3f42802..13d5483 100644 --- a/internal/core/runner/form/runner_test.go +++ b/internal/core/runner/form/runner_test.go @@ -157,7 +157,7 @@ func TestRunner_Apply(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := runner.Apply(ctx, tt.input, nil) + result := runner.Apply(ctx, tt.input) if result != tt.expected { t.Errorf("Apply() = %v, want %v", result, tt.expected) } @@ -209,7 +209,7 @@ func TestRunner_ApplyBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := runner.ApplyBody(ctx, tt.input, nil) + result := runner.ApplyBody(ctx, tt.input) if !reflect.DeepEqual(result, tt.expected) { t.Errorf("ApplyBody() = %v, want %v", result, tt.expected) } @@ -232,7 +232,7 @@ func TestRunner_MapHeaders(t *testing.T) { "Content-Type": "application/json", } - result := runner.MapHeaders(ctx, input, nil) + result := runner.MapHeaders(ctx, input) if !reflect.DeepEqual(result, expected) { t.Errorf("MapHeaders() = %v, want %v", result, expected) } diff --git a/internal/report/html/html.go b/internal/report/html/html.go index 2032aa7..160ce08 100644 --- a/internal/report/html/html.go +++ b/internal/report/html/html.go @@ -3,14 +3,15 @@ package html import ( "embed" "fmt" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/runner/interfaces" - "github.com/goccy/go-json" "html/template" "os" "path/filepath" "time" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/goccy/go-json" + "github.com/apiqube/cli/internal/core/runner/save" ) @@ -76,7 +77,7 @@ func buildReportViewData(ctx interfaces.ExecutionContext) *ViewData { return nil } - var reportMap = make(map[string]*ManifestReport) + reportMap := make(map[string]*ManifestReport) var totalCases, passedCases, failedCases int var totalTime time.Duration @@ -200,7 +201,7 @@ func (g *ReportGenerator) Generate(ctx interfaces.ExecutionContext) error { data := buildReportViewData(ctx) reportsDir := "reports" - if err := os.MkdirAll(reportsDir, 0755); err != nil { + if err := os.MkdirAll(reportsDir, 0o755); err != nil { return fmt.Errorf("failed to create reports directory: %w", err) } outputPath := filepath.Join(reportsDir, fmt.Sprintf("report_%s.html", time.Now().Format("2006-01-02-150405"))) diff --git a/internal/validate/validator_test.go b/internal/validate/validator_test.go index 6aa4aa0..19c660e 100644 --- a/internal/validate/validator_test.go +++ b/internal/validate/validator_test.go @@ -299,12 +299,6 @@ var ( }, }, }, - Pass: []*tests.Pass{ - { - From: "headers", - Map: map[string]string{"Authorization": "Bearer token"}, - }, - }, Timeout: time.Second, Parallel: false, Details: []string{