diff --git a/go.mod b/go.mod index 35f452639c..abc9949ec0 100644 --- a/go.mod +++ b/go.mod @@ -110,6 +110,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/glog v1.2.4 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -132,7 +133,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/microcosm-cc/bluemonday v1.0.26 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 09f10eb945..2088a9f6c1 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -311,6 +313,8 @@ github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3v github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= diff --git a/internal/analysisengine/engine.go b/internal/analysisengine/engine.go index 577b3d33de..01e9293476 100644 --- a/internal/analysisengine/engine.go +++ b/internal/analysisengine/engine.go @@ -29,6 +29,8 @@ type ClusterInfo struct { Region string CloudProvider string Version string + Type string // e.g. "rosa", "osd", "aro" + Hypershift bool } // Config holds configuration for the analysis engine. diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index c1dfc323b2..fafa2f8c66 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -71,6 +71,12 @@ func (ps *PromptStore) loadTemplates(filesystem fs.FS) error { }) } +// RegisterTemplates loads additional templates from the given filesystem, +// overwriting any existing templates with the same ID. +func (ps *PromptStore) RegisterTemplates(templatesFS fs.FS) error { + return ps.loadTemplates(templatesFS) +} + func (ps *PromptStore) GetTemplate(id string) (*PromptTemplate, error) { template, exists := ps.templates[id] if !exists { diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index f1565da42e..7be494358d 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -2,6 +2,7 @@ package prompts import ( "testing" + "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,6 +13,33 @@ func TestNewPromptStore(t *testing.T) { require.NoError(t, err) require.NotNil(t, store) assert.Greater(t, len(store.templates), 0, "Should have loaded some templates") + + _, err = store.GetTemplate("default") + assert.NoError(t, err, "default template should be loaded") +} + +func TestRegisterTemplates_OverwritesExisting(t *testing.T) { + store, err := NewPromptStore(DefaultTemplates()) + require.NoError(t, err) + + original, err := store.GetTemplate("default") + require.NoError(t, err) + require.NotNil(t, original) + + replacementYAML := `system_prompt: "replacement system prompt" +user_prompt: "replacement user prompt" +` + overrideFS := fstest.MapFS{ + "default.yaml": &fstest.MapFile{Data: []byte(replacementYAML)}, + } + + err = store.RegisterTemplates(overrideFS) + require.NoError(t, err) + + updated, err := store.GetTemplate("default") + require.NoError(t, err) + assert.Equal(t, "replacement system prompt", updated.SystemPrompt) + assert.Equal(t, "replacement user prompt", updated.UserPrompt) } func TestGetTemplate(t *testing.T) { diff --git a/pkg/krknai/aggregator/aggregator.go b/pkg/krknai/aggregator/aggregator.go index d06b1c4f6c..4cd30437fb 100644 --- a/pkg/krknai/aggregator/aggregator.go +++ b/pkg/krknai/aggregator/aggregator.go @@ -15,6 +15,15 @@ import ( "gopkg.in/yaml.v3" ) +// ClusterInfo holds cluster metadata for krkn-ai analysis. +type ClusterInfo struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` // Combined: "cloud/platform[-hcp]", e.g. "aws/rosa-hcp" + Region string `json:"region,omitempty" yaml:"region,omitempty"` + Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` // e.g. "stage", "production", "integration" +} + const ( // Default file paths relative to results directory allCSVPath = "reports/all.csv" @@ -29,6 +38,7 @@ const ( type KrknAIAggregator struct { logger logr.Logger topScenariosCount int + clusterInfo *ClusterInfo } // KrknAIData holds aggregated krkn-ai results with minimal context. @@ -39,6 +49,7 @@ type KrknAIData struct { HealthCheckReport []HealthCheckResult `json:"healthCheckReport"` LogArtifacts []internalAggregator.LogEntry `json:"logArtifacts"` ConfigSummary string `json:"configSummary,omitempty"` + ClusterInfo *ClusterInfo `json:"clusterInfo,omitempty"` } // KrknAISummary provides high-level statistics about the chaos test run. @@ -89,6 +100,16 @@ func (a *KrknAIAggregator) WithTopScenariosCount(count int) *KrknAIAggregator { return a } +// WithClusterInfo sets cluster metadata to include in collected data. +// A defensive copy is stored so later mutations by the caller don't affect stored data. +func (a *KrknAIAggregator) WithClusterInfo(info *ClusterInfo) *KrknAIAggregator { + if info != nil { + cp := *info + a.clusterInfo = &cp + } + return a +} + // Collect gathers krkn-ai results from the specified directory. func (a *KrknAIAggregator) Collect(ctx context.Context, resultsDir string) (*KrknAIData, error) { a.logger.Info("collecting krkn-ai results", "resultsDir", resultsDir) @@ -98,6 +119,10 @@ func (a *KrknAIAggregator) Collect(ctx context.Context, resultsDir string) (*Krk } data := &KrknAIData{} + if a.clusterInfo != nil { + cp := *a.clusterInfo + data.ClusterInfo = &cp + } var collectionErrors []string // Collect scenario results from all.csv diff --git a/pkg/krknai/aggregator/aggregator_test.go b/pkg/krknai/aggregator/aggregator_test.go index e6509ad02b..5f88b90452 100644 --- a/pkg/krknai/aggregator/aggregator_test.go +++ b/pkg/krknai/aggregator/aggregator_test.go @@ -313,3 +313,44 @@ scenario: require.NoError(t, os.WriteFile(filepath.Join(resultsDir, "krkn-ai.yaml"), []byte(configYAML), 0o644)) } + +func TestWithClusterInfo_DefensiveCopy(t *testing.T) { + info := &ClusterInfo{ + ID: "original-id", + Version: "4.14.0", + Type: "aws/rosa-hcp", + Region: "us-east-1", + Environment: "stage", + } + + agg := NewKrknAIAggregator(context.Background()) + agg.WithClusterInfo(info) + + // Mutate the caller's struct after passing it in + info.ID = "mutated-id" + info.Region = "eu-west-1" + + assert.Equal(t, "original-id", agg.clusterInfo.ID, "stored copy must be isolated from caller mutation") + assert.Equal(t, "us-east-1", agg.clusterInfo.Region, "stored copy must be isolated from caller mutation") +} + +func TestCollect_ClusterInfoIsolation(t *testing.T) { + tempDir := t.TempDir() + resultsDir := filepath.Join(tempDir, "results") + reportsDir := filepath.Join(resultsDir, "reports") + require.NoError(t, os.MkdirAll(reportsDir, 0o755)) + createKrknAITestFiles(t, resultsDir, reportsDir) + + info := &ClusterInfo{ID: "test-cluster", Version: "4.14.0"} + agg := NewKrknAIAggregator(context.Background()) + agg.WithClusterInfo(info) + + data, err := agg.Collect(context.Background(), resultsDir) + require.NoError(t, err) + require.NotNil(t, data.ClusterInfo) + + // The output ClusterInfo should be a separate copy from the aggregator's internal one + assert.Equal(t, "test-cluster", data.ClusterInfo.ID) + data.ClusterInfo.ID = "mutated-output" + assert.Equal(t, "test-cluster", agg.clusterInfo.ID, "aggregator's stored copy must not be affected by output mutation") +} diff --git a/pkg/krknai/analysisengine/engine.go b/pkg/krknai/analysisengine/engine.go index b80afa802e..bd6fe8eeef 100644 --- a/pkg/krknai/analysisengine/engine.go +++ b/pkg/krknai/analysisengine/engine.go @@ -1,14 +1,20 @@ package analysisengine import ( + "bytes" "context" "embed" "fmt" + "html/template" "io/fs" "os" "path/filepath" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/microcosm-cc/bluemonday" "github.com/openshift/osde2e/internal/analysisengine" "github.com/openshift/osde2e/internal/llm" "github.com/openshift/osde2e/internal/llm/tools" @@ -18,21 +24,22 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed prompts/krknai.yaml -var krknaiTemplatesFS embed.FS +//go:embed prompts/* +var krknPrompts embed.FS const ( analysisDirName = "llm-analysis" summaryFileName = "summary.yaml" - // krknAIPromptTemplate is the prompt template ID for krkn-ai analysis. krknAIPromptTemplate = "krknai" + htmlTemplatePath = "prompts/report.html" ) // Config holds configuration for the krkn-ai analysis engine. type Config struct { analysisengine.BaseConfig - TopScenariosCount int // Number of top scenarios to include (default: 10) + TopScenariosCount int // Number of top scenarios to include (default: 10) + ReportFormat string // "json" (default), "markdown", or "html" } // Engine analyzes krkn-ai chaos test results using LLM. @@ -60,14 +67,17 @@ func New(ctx context.Context, config *Config) (*Engine, error) { agg.WithTopScenariosCount(config.TopScenariosCount) } - templatesFS, err := fs.Sub(krknaiTemplatesFS, "prompts") + promptStore, err := prompts.NewPromptStore(prompts.DefaultTemplates()) if err != nil { - return nil, fmt.Errorf("failed to access embedded prompts: %w", err) + return nil, fmt.Errorf("failed to initialize prompt store: %w", err) } - promptStore, err := prompts.NewPromptStore(templatesFS) + localFS, err := fs.Sub(krknPrompts, "prompts") if err != nil { - return nil, fmt.Errorf("failed to initialize prompt store: %w", err) + return nil, fmt.Errorf("failed to load krkn-ai prompt templates: %w", err) + } + if err := promptStore.RegisterTemplates(localFS); err != nil { + return nil, fmt.Errorf("failed to register krkn-ai prompt templates: %w", err) } client, err := llm.NewGeminiClient(ctx, config.APIKey) @@ -88,6 +98,12 @@ func New(ctx context.Context, config *Config) (*Engine, error) { }, nil } +// WithClusterInfo sets cluster metadata on the aggregator for inclusion in collected data. +func (e *Engine) WithClusterInfo(info *krknAggregator.ClusterInfo) *Engine { + e.aggregator.WithClusterInfo(info) + return e +} + // Run executes the krkn-ai analysis workflow. func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) { // Collect krkn-ai results @@ -99,7 +115,7 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) { // Create tool registry with log artifacts for read_file tool toolRegistry := tools.NewRegistry(data.LogArtifacts) - // Prepare template variables + // Prepare template variables from collected data vars := map[string]any{ "Summary": data.Summary, "TopScenarios": data.TopScenarios, @@ -108,6 +124,9 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) { "LogArtifacts": data.LogArtifacts, "ConfigSummary": data.ConfigSummary, } + if data.ClusterInfo != nil { + vars["ClusterInfo"] = data.ClusterInfo + } // Render prompt using prompt store userPrompt, llmConfig, err := e.promptStore.RenderPrompt(krknAIPromptTemplate, vars) @@ -134,10 +153,19 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) { return nil, fmt.Errorf("LLM analysis failed: %w", err) } + content := result.Content + if e.config.ReportFormat == "html" { + var err error + content, err = markdownToHTML(content) + if err != nil { + return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err) + } + } + // Build analysis result analysisResult := &analysisengine.Result{ Status: "completed", - Content: result.Content, + Content: content, Prompt: userPrompt, Metadata: map[string]any{ "analysis_type": "krknai", @@ -181,6 +209,7 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato summary := map[string]any{ "timestamp": time.Now().Format(time.RFC3339), "analysis_type": "krknai", + "cluster_info": data.ClusterInfo, "run_summary": map[string]any{ "total_scenarios": data.Summary.TotalScenarioCount, "successful_scenarios": data.Summary.SuccessfulScenarioCount, @@ -212,6 +241,30 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato return nil } +func markdownToHTML(content string) (string, error) { + htmlTmplBytes, err := krknPrompts.ReadFile(htmlTemplatePath) + if err != nil { + return "", fmt.Errorf("failed to read HTML template: %w", err) + } + + tmpl, err := template.New("report").Parse(string(htmlTmplBytes)) + if err != nil { + return "", fmt.Errorf("failed to parse HTML template: %w", err) + } + + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags | mdhtml.HrefTargetBlank}) + unsafeBody := markdown.ToHTML([]byte(content), p, renderer) + safeBody := bluemonday.UGCPolicy().SanitizeBytes(unsafeBody) + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ Body template.HTML }{Body: template.HTML(string(safeBody))}); err != nil { + return "", fmt.Errorf("failed to execute HTML template: %w", err) + } + + return buf.String(), nil +} + // sendNotifications sends analysis results to configured reporters. func (e *Engine) sendNotifications(ctx context.Context, result *analysisengine.Result) { reporterResult := &reporter.AnalysisResult{ diff --git a/pkg/krknai/analysisengine/engine_test.go b/pkg/krknai/analysisengine/engine_test.go index 102c3ce998..06cd98f495 100644 --- a/pkg/krknai/analysisengine/engine_test.go +++ b/pkg/krknai/analysisengine/engine_test.go @@ -12,7 +12,7 @@ import ( "github.com/openshift/osde2e/internal/llm/tools" "github.com/openshift/osde2e/internal/prompts" "github.com/openshift/osde2e/internal/reporter" - krknAggregator "github.com/openshift/osde2e/pkg/krknai/aggregator" + krknAgg "github.com/openshift/osde2e/pkg/krknai/aggregator" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" @@ -50,13 +50,175 @@ func TestNew_ValidConfig(t *testing.T) { assert.Contains(t, err.Error(), "results directory is required") } -func TestEmbeddedPromptTemplate(t *testing.T) { - // Verify the embedded prompt template loads correctly - data, err := krknaiTemplatesFS.ReadFile("prompts/krknai.yaml") +func TestPromptTemplatesAvailable(t *testing.T) { + store := newTestPromptStore(t) + + tmpl, err := store.GetTemplate("krknai") require.NoError(t, err) - assert.Contains(t, string(data), "system_prompt") - assert.Contains(t, string(data), "user_prompt") - assert.Contains(t, string(data), "chaos engineering") + assert.Contains(t, tmpl.SystemPrompt, "chaos engineering") + assert.Contains(t, tmpl.UserPrompt, "Summary") + + assert.Contains(t, tmpl.SystemPrompt, "markdown") + assert.Contains(t, tmpl.SystemPrompt, "genetic algorithm") +} + +func TestRenderKrknAIPrompt(t *testing.T) { + store := newTestPromptStore(t) + + variables := map[string]any{ + "ClusterInfo": &krknAgg.ClusterInfo{ + ID: "abc-123", + Version: "4.17.3", + Type: "aws/rosa-hcp", + Region: "us-east-1", + Environment: "stage", + }, + "Summary": map[string]any{ + "TotalScenarioCount": 30, + "SuccessfulScenarioCount": 27, + "FailedScenarioCount": 3, + "Generations": 3, + "MaxFitnessScore": 8.75, + "AvgFitnessScore": 4.32, + "ScenarioTypes": []string{"node-cpu-hog", "node-memory-hog", "pod-scenarios"}, + }, + "TopScenarios": []map[string]any{ + { + "Scenario": "node-cpu-hog", + "GenerationID": 2, + "ScenarioID": 15, + "FitnessScore": 8.75, + "HealthCheckResponseTimeScore": 6.50, + "HealthCheckFailureScore": 2.25, + "KrknFailureScore": 0.0, + "Parameters": "node_selector: node-role.kubernetes.io/worker", + }, + }, + "FailedScenarios": []map[string]any{ + { + "Scenario": "dns-outage", + "GenerationID": 1, + "ScenarioID": 7, + "KrknFailureScore": -1.0, + "Parameters": "namespace: openshift-dns", + }, + }, + "HealthCheckReport": []map[string]any{ + { + "ScenarioID": 15, + "ComponentName": "console", + "MinResponseTime": 12.5, + "MaxResponseTime": 850.3, + "AverageResponseTime": 245.7, + "SuccessCount": 48, + "FailureCount": 2, + }, + }, + "LogArtifacts": []map[string]any{ + {"Source": "/results/reports/all.csv", "LineCount": 31}, + {"Source": "/results/krkn-ai.yaml", "LineCount": 85}, + }, + "ConfigSummary": "generations: 3\npopulation_size: 10\n", + } + + userPrompt, config, err := store.RenderPrompt("krknai", variables) + require.NoError(t, err) + require.NotNil(t, config) + + assert.Contains(t, userPrompt, "id=abc-123") + assert.Contains(t, userPrompt, "version=4.17.3") + assert.Contains(t, userPrompt, "type=aws/rosa-hcp") + assert.Contains(t, userPrompt, "region=us-east-1") + assert.Contains(t, userPrompt, "env=stage") + + assert.Contains(t, userPrompt, "30 scenarios") + assert.Contains(t, userPrompt, "27 ok") + assert.Contains(t, userPrompt, "3 failed") + assert.Contains(t, userPrompt, "max=8.75") + assert.Contains(t, userPrompt, "fitness=8.75") + assert.Contains(t, userPrompt, "node_selector: node-role.kubernetes.io/worker") + assert.Contains(t, userPrompt, "dns-outage") + assert.Contains(t, userPrompt, "console") + assert.Contains(t, userPrompt, "avg=245.70ms") + assert.Contains(t, userPrompt, "/results/reports/all.csv (31L)") + assert.Contains(t, userPrompt, "generations: 3") + + assert.NotNil(t, config.SystemInstruction) + assert.Contains(t, *config.SystemInstruction, "chaos engineering analyst") + assert.Contains(t, *config.SystemInstruction, "genetic algorithm") +} + +func TestRun_MarkdownReportFormat(t *testing.T) { + tempDir := t.TempDir() + reportsDir := filepath.Join(tempDir, "reports") + require.NoError(t, os.MkdirAll(reportsDir, 0o755)) + + createTestResultFiles(t, tempDir, reportsDir) + + ctx := context.Background() + agg := krknAgg.NewKrknAIAggregator(ctx) + promptStore := newTestPromptStore(t) + + mockClient := &mockLLMClient{ + response: &llm.AnalysisResult{ + Content: "# Krkn-AI Chaos Test Report\n\n## Executive Summary\nCluster shows moderate resilience.", + }, + } + + engine := &Engine{ + config: &Config{ + BaseConfig: analysisengine.BaseConfig{ArtifactsDir: tempDir, APIKey: "fake-key"}, + ReportFormat: "markdown", + }, + aggregator: agg, + promptStore: promptStore, + llmClient: mockClient, + reporterRegistry: newTestReporterRegistry(), + } + + result, err := engine.Run(ctx) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Content, "Chaos Test Report") +} + +func TestRun_HTMLReportFormat(t *testing.T) { + tempDir := t.TempDir() + reportsDir := filepath.Join(tempDir, "reports") + require.NoError(t, os.MkdirAll(reportsDir, 0o755)) + + createTestResultFiles(t, tempDir, reportsDir) + + ctx := context.Background() + agg := krknAgg.NewKrknAIAggregator(ctx) + promptStore := newTestPromptStore(t) + + mockClient := &mockLLMClient{ + response: &llm.AnalysisResult{ + Content: "# Krkn-AI Chaos Test Report\n\n## Executive Summary\nCluster shows **moderate** resilience.\n\n| Metric | Value |\n|--------|-------|\n| Total | 5 |\n", + }, + } + + engine := &Engine{ + config: &Config{ + BaseConfig: analysisengine.BaseConfig{ArtifactsDir: tempDir, APIKey: "fake-key"}, + ReportFormat: "html", + }, + aggregator: agg, + promptStore: promptStore, + llmClient: mockClient, + reporterRegistry: newTestReporterRegistry(), + } + + result, err := engine.Run(ctx) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Contains(t, result.Content, "") + assert.Contains(t, result.Content, "