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/.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 diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go index 8aeff92..27b9391 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", manifestID) + } + } + } 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/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 58bebcc..edfd28e 100644 --- a/examples/complex-http-tests/http_test.yaml +++ b/examples/complex-http-tests/http_test.yaml @@ -7,27 +7,19 @@ metadata: spec: target: http://127.0.0.1:8081 cases: + - name: Fetch User From Server + alias: fetch-user + method: GET + endpoint: /users/3 + assert: + - target: status + equals: 200 - - name: Create New Array of User + - name: Create User With Data From Previous Response method: POST - endpoint: /users-batch + endpoint: /users assert: - target: status equals: 201 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.user }}" \ 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..646dd90 --- /dev/null +++ b/internal/core/runner/depends/builder_v2.go @@ -0,0 +1,59 @@ +package depends + +// AddRule adds a new rule to the registry +func (gb *GraphBuilderV2) AddRule(rule DependencyRule) { + gb.registry.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/dependencies.go b/internal/core/runner/depends/dependencies.go index 9c97f37..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" @@ -10,12 +11,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 @@ -32,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 @@ -44,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() @@ -57,25 +54,51 @@ 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 + // 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{ @@ -101,7 +124,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 new file mode 100644 index 0000000..88fc206 --- /dev/null +++ b/internal/core/runner/depends/graph_builder_v2.go @@ -0,0 +1,515 @@ +package depends + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" +) + +var priorityOrder = map[string]int{ + manifests.ValuesKind: 1, + manifests.ServerKind: 10, + manifests.ServiceKind: 20, + manifests.HttpTestKind: 30, + manifests.HttpLoadTestKind: 40, +} + +// GraphBuilderV2 builds dependency graphs using rule-based analysis +type GraphBuilderV2 struct { + registry *RuleRegistry + manifestPriority map[string]int + templateRegex *regexp.Regexp +} + +// 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 +} + +// 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) +} + +// 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 +} + +// 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*}}`), + } +} + +// 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 1: Initialize manifest priorities and collect aliases + if err := gb.initializeManifests(manifests, result); err != nil { + return nil, err + } + + // Step 2: Analyze dependencies using all rules (but ignore explicit dependencies) + allDependencies, err := gb.analyzeAllDependencies(manifests) + if err != nil { + return nil, err + } + + // Step 3: Separate inter-manifest and intra-manifest dependencies + gb.categorizeDependencies(allDependencies, result) + + // Step 4: Build adjacency graph from dependencies + gb.buildAdjacencyGraph(result) + + // Step 5: Calculate save requirements + gb.calculateSaveRequirements(result) + + // 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 result, nil +} + +// 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 nil +} + +// analyzeAllDependencies analyzes dependencies using all registered rules +func (gb *GraphBuilderV2) analyzeAllDependencies(manifests []manifests.Manifest) ([]Dependency, error) { + var allDependencies []Dependency + + 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 + } + + 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...) + } + } + } + + // Add smart template-based dependencies + smartDeps, err := gb.analyzeSmartTemplateDependencies(manifests, allDependencies) + if err != nil { + return nil, err + } + allDependencies = append(allDependencies, smartDeps...) + + return allDependencies, nil +} + +// analyzeSmartTemplateDependencies creates inter-manifest dependencies based on template analysis +func (gb *GraphBuilderV2) analyzeSmartTemplateDependencies(mans []manifests.Manifest, _ []Dependency) ([]Dependency, error) { + var smartDeps []Dependency + aliasToManifest := make(map[string]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() + } + } + } + } + + // Analyze template references and create inter-manifest dependencies + for _, manifest := range mans { + manifestID := manifest.GetID() + + if httpTest, ok := manifest.(*api.Http); ok { + // Find all template references in this manifest + templateRefs := gb.extractAllTemplateReferences(httpTest) + + // Group by alias and create dependencies + aliasGroups := make(map[string][]string) + for _, ref := range templateRefs { + aliasGroups[ref.Alias] = append(aliasGroups[ref.Alias], ref.Path) + } + + 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 smartDeps, nil +} + +// extractAllTemplateReferences extracts all template references from an HTTP test +func (gb *GraphBuilderV2) extractAllTemplateReferences(httpTest *api.Http) []TemplateReference { + var references []TemplateReference + + for _, testCase := range httpTest.Spec.Cases { + // Check endpoint + refs := gb.findTemplateReferencesInString(testCase.Endpoint) + references = append(references, refs...) + + // Check URL + refs = gb.findTemplateReferencesInString(testCase.Url) + references = append(references, refs...) + + // Check headers + for _, value := range testCase.Headers { + refs = gb.findTemplateReferencesInString(value) + references = append(references, refs...) + } + + // Check body recursively + if testCase.Body != nil { + refs = gb.findTemplateReferencesInValue(testCase.Body) + references = append(references, refs...) + } + + // Check assertions + for _, assert := range testCase.Assert { + if assert.Template != "" { + refs = gb.findTemplateReferencesInString(assert.Template) + references = append(references, refs...) + } + } + } + + return references +} + +// findTemplateReferencesInString finds template references in a string +func (gb *GraphBuilderV2) findTemplateReferencesInString(str string) []TemplateReference { + var references []TemplateReference + matches := gb.templateRegex.FindAllStringSubmatch(str, -1) + + for _, match := range matches { + if len(match) >= 3 { + references = append(references, TemplateReference{ + Alias: match[1], + Path: match[2], + }) + } + } + + return references +} + +// findTemplateReferencesInValue recursively finds template references in any value +func (gb *GraphBuilderV2) findTemplateReferencesInValue(value any) []TemplateReference { + var references []TemplateReference + + 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 references +} + +// 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) + + 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) + } + } +} + +// 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) + + if result.Graph[toManifest] == nil { + result.Graph[toManifest] = make([]string, 0) + } + result.Graph[toManifest] = append(result.Graph[toManifest], fromManifest) + } +} + +// 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 { + toManifest := gb.getBaseManifestID(dep.To) + + // Update save requirement for the target manifest + req := result.SaveRequirements[toManifest] + req.Required = true + req.Consumers = append(req.Consumers, dep.From) + + if paths, ok := dep.Metadata["required_paths"].([]string); ok { + req.RequiredPaths = append(req.RequiredPaths, paths...) + req.Paths = append(req.Paths, paths...) + } + + 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 + } + } + } + } + + // 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 + + 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...) + } + } + + result.SaveRequirements[manifestID] = req + } +} + +// 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 { + id := manifest.GetID() + inDegree[id] = 0 + } + + // Calculate in-degrees from all inter-manifest dependencies + for _, dep := range dependencies { + fromBase := gb.getBaseManifestID(dep.From) + toBase := gb.getBaseManifestID(dep.To) + if fromBase != toBase { + if _, exists := inDegree[fromBase]; exists && dep.Type != DependencyTypeTemplate { + inDegree[fromBase]++ + } + } + } + + // 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 { + zeroInDegreeNodes = append(zeroInDegreeNodes, &Node{ + ID: id, + Priority: gb.manifestPriority[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 + }) + + executionOrder := make([]string, 0, len(manifests)) + queue := zeroInDegreeNodes + + 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 == 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...) + } + + // 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) + } + + return executionOrder, nil +} + +// getManifestPriority returns priority for a manifest based on its kind +func (gb *GraphBuilderV2) getManifestPriority(manifest manifests.Manifest) int { + kind := manifest.GetKind() + + // Find kind priority rule + for _, rule := range gb.registry.GetRules() { + if kindRule, ok := rule.(*KindPriorityRule); ok { + return kindRule.GetKindPriority(kind) + } + } + + 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 +} + +// 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 new file mode 100644 index 0000000..40d0a66 --- /dev/null +++ b/internal/core/runner/depends/graph_test.go @@ -0,0 +1,669 @@ +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" + + "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", + }, + }, + }, + }, + }, + } + + 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) + + expectedOrder := []string{ + mans[0].GetID(), + mans[1].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]) + } + } + }) + + // 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") + + // 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", + }, + }, + }, + }, + }, + &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 }}", + }, + }, + }, + }, + }, + }, + } + + 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) != 4 { + 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]) + } + } + }) + + // Test Case 5: Multiple manifests with hard 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{ + 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{ + "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: "Fetch all user's roles from API", + 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 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 }}", + "role": "{{ users-roles.roles.3 }}", + }, + }, + }, + { + 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 }}", + "number": "{{ Values.values-test-2.number }}", + }, + }, + }, + }, + }, + }, + } + + 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()) + 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 +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..6921c21 --- /dev/null +++ b/internal/core/runner/depends/http_rules.go @@ -0,0 +1,339 @@ +package depends + +import ( + "fmt" + "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 +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*}}`) + var newPath string + + 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()) + 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() { + 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..4e9b9ef --- /dev/null +++ b/internal/core/runner/depends/rules.go @@ -0,0 +1,256 @@ +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 +} + +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, + } +} + +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 7d049c0..6129447 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -5,10 +5,9 @@ import ( "fmt" "sync" - "github.com/apiqube/cli/internal/report" - "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/runner/depends" "github.com/apiqube/cli/internal/core/runner/hooks" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -29,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) @@ -71,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 { @@ -133,19 +161,228 @@ 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 + 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/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/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() 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..160ce08 --- /dev/null +++ b/internal/report/html/html.go @@ -0,0 +1,222 @@ +package html + +import ( + "embed" + "fmt" + "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" +) + +//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 + } + + 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, 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"))) + 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) } 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{