diff --git a/internal/execx/runner_additional_test.go b/internal/execx/runner_additional_test.go deleted file mode 100644 index b803188..0000000 --- a/internal/execx/runner_additional_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package execx - -import ( - "context" - "strings" - "testing" -) - -// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step. -// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T). -// Why: covers error branch where command fails without producing output. -func TestRunnerRunFailureWithoutOutput(t *testing.T) { - r := &Runner{} - out, err := r.Run(context.Background(), "sh", "-c", "exit 3") - if err == nil { - t.Fatalf("expected failure") - } - if out != "" { - t.Fatalf("expected empty output, got %q", out) - } -} - -// TestRunnerLogfNoLogger runs one orchestration or CLI step. -// Signature: TestRunnerLogfNoLogger(t *testing.T). -// Why: covers no-op logging path. -func TestRunnerLogfNoLogger(t *testing.T) { - r := &Runner{} - r.logf("hello %s", "world") -} - -// TestRunnerCommandMissing runs one orchestration or CLI step. -// Signature: TestRunnerCommandMissing(t *testing.T). -// Why: covers false branch of command existence checks. -func TestRunnerCommandMissing(t *testing.T) { - r := &Runner{} - if r.CommandExists("definitely-not-a-real-command-ananke") { - t.Fatalf("expected missing command to be false") - } -} - -// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step. -// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T). -// Why: covers kubeconfig environment injection branch in command runner. -func TestRunnerInjectsKubeconfigEnv(t *testing.T) { - r := &Runner{Kubeconfig: "/tmp/test-kubeconfig"} - out, err := r.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"") - if err != nil { - t.Fatalf("runner command failed: %v", err) - } - if strings.TrimSpace(out) != "/tmp/test-kubeconfig" { - t.Fatalf("expected kubeconfig env to propagate, got %q", out) - } -} diff --git a/internal/execx/runner_test.go b/internal/execx/runner_test.go deleted file mode 100644 index 63acf0a..0000000 --- a/internal/execx/runner_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package execx - -import ( - "bytes" - "context" - "log" - "strings" - "testing" -) - -// TestRunnerDryRun runs one orchestration or CLI step. -// Signature: TestRunnerDryRun(t *testing.T). -// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. -func TestRunnerDryRun(t *testing.T) { - var buf bytes.Buffer - r := &Runner{ - DryRun: true, - Logger: log.New(&buf, "", 0), - } - out, err := r.Run(context.Background(), "echo", "hello") - if err != nil { - t.Fatalf("dry-run should not fail: %v", err) - } - if out != "" { - t.Fatalf("expected empty dry-run output, got %q", out) - } - if !strings.Contains(buf.String(), "DRY-RUN: echo hello") { - t.Fatalf("expected dry-run log entry, got %q", buf.String()) - } -} - -// TestRunnerRunSuccess runs one orchestration or CLI step. -// Signature: TestRunnerRunSuccess(t *testing.T). -// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. -func TestRunnerRunSuccess(t *testing.T) { - r := &Runner{} - out, err := r.Run(context.Background(), "sh", "-c", "printf ok") - if err != nil { - t.Fatalf("expected command success: %v", err) - } - if out != "ok" { - t.Fatalf("expected output ok, got %q", out) - } -} - -// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step. -// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T). -// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. -func TestRunnerRunFailureIncludesOutput(t *testing.T) { - r := &Runner{} - out, err := r.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1") - if err == nil { - t.Fatalf("expected command failure") - } - if strings.TrimSpace(out) != "boom" { - t.Fatalf("expected stderr to be preserved, got %q", out) - } -} - -// TestRunnerCommandExists runs one orchestration or CLI step. -// Signature: TestRunnerCommandExists(t *testing.T). -// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. -func TestRunnerCommandExists(t *testing.T) { - r := &Runner{} - if !r.CommandExists("sh") { - t.Fatalf("expected shell command to exist") - } -} diff --git a/internal/execx/testing_hooks.go b/internal/execx/testing_hooks.go new file mode 100644 index 0000000..fd106bf --- /dev/null +++ b/internal/execx/testing_hooks.go @@ -0,0 +1,9 @@ +package execx + +// TestHookLogf runs one orchestration or CLI step. +// Signature: TestHookLogf(r *Runner, format string, args ...any). +// Why: top-level testing module still needs to hit the nil-logger branch +// directly while runner tests move out of the root module. +func TestHookLogf(r *Runner, format string, args ...any) { + r.logf(format, args...) +} diff --git a/internal/metrics/exporter_additional_test.go b/internal/metrics/exporter_additional_test.go deleted file mode 100644 index 90b7774..0000000 --- a/internal/metrics/exporter_additional_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package metrics - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -// TestExporterHealthzAndEscaping runs one orchestration or CLI step. -// Signature: TestExporterHealthzAndEscaping(t *testing.T). -// Why: covers health endpoint and label escaping branches in metrics renderer. -func TestExporterHealthzAndEscaping(t *testing.T) { - e := New() - e.UpdateSample(Sample{ - Name: `Sta"tera`, - Target: `statera\host`, - Status: `O"B`, - LastError: "x", - }) - - h := e.Handler("/custom") - healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil) - healthRR := httptest.NewRecorder() - h.ServeHTTP(healthRR, healthReq) - if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" { - t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String()) - } - - metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil) - metricsRR := httptest.NewRecorder() - h.ServeHTTP(metricsRR, metricsReq) - body := metricsRR.Body.String() - if !strings.Contains(body, `source="Sta\\\"tera"`) { - t.Fatalf("expected escaped source label, got:\n%s", body) - } - if !strings.Contains(body, `target="statera\\\\host"`) { - t.Fatalf("expected escaped target label, got:\n%s", body) - } - if !strings.Contains(body, "ananke_ups_error") { - t.Fatalf("expected error metric line in output") - } -} - -// TestBoolNumAndSafeHelpers runs one orchestration or CLI step. -// Signature: TestBoolNumAndSafeHelpers(t *testing.T). -// Why: directly covers remaining helper branches. -func TestBoolNumAndSafeHelpers(t *testing.T) { - if boolNum(true) != 1 || boolNum(false) != 0 { - t.Fatalf("unexpected boolNum values") - } - if got := safe(`a"b\c`); got != `a\"b\\c` { - t.Fatalf("unexpected escaped string: %q", got) - } -} - -// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step. -// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T). -// Why: verifies quality-gate metrics are surfaced on /metrics for Grafana suite -// pass-rate tracking. -func TestExporterAppendsQualityGateMetrics(t *testing.T) { - tmp := t.TempDir() - metricsPath := filepath.Join(tmp, "quality-gate.prom") - content := strings.Join([]string{ - `# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`, - `# TYPE ananke_quality_gate_runs_total counter`, - `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`, - `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`, - "", - }, "\n") - if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil { - t.Fatalf("write quality metrics file: %v", err) - } - t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath) - - e := New() - req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - rr := httptest.NewRecorder() - e.Handler("/metrics").ServeHTTP(rr, req) - body := rr.Body.String() - if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) { - t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body) - } -} - -// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step. -// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T). -// Why: ensures Grafana panels see zero-value quality-gate series instead of -// "no data" before host-side quality-gate files exist. -func TestExporterDefaultsQualityGateMetrics(t *testing.T) { - t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom")) - e := New() - req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - rr := httptest.NewRecorder() - e.Handler("/metrics").ServeHTTP(rr, req) - body := rr.Body.String() - if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) { - t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body) - } - if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) { - t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body) - } -} diff --git a/internal/metrics/exporter_test.go b/internal/metrics/exporter_test.go deleted file mode 100644 index 9f0b4ce..0000000 --- a/internal/metrics/exporter_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package metrics - -import ( - "net/http/httptest" - "strings" - "testing" - "time" -) - -// TestExporterEmitsCoreMetrics runs one orchestration or CLI step. -// Signature: TestExporterEmitsCoreMetrics(t *testing.T). -// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. -func TestExporterEmitsCoreMetrics(t *testing.T) { - e := New() - e.UpdateBudget(321) - e.UpdateSample(Sample{ - Name: "Pyrphoros", - Target: "pyrphoros@localhost", - OnBattery: true, - LowBattery: false, - RuntimeSecond: 412, - BatteryCharge: 81.5, - LoadPercent: 27.4, - PowerNominalW: 510, - ThresholdSec: 354, - Trigger: true, - BreachCount: 2, - Status: "OB", - UpdatedAt: time.Unix(1710000000, 0).UTC(), - }) - e.MarkShutdown("ups-threshold") - - req := httptest.NewRequest("GET", "/metrics", nil) - rr := httptest.NewRecorder() - e.Handler("/metrics").ServeHTTP(rr, req) - body := rr.Body.String() - - mustContain := []string{ - "ananke_shutdown_budget_seconds 321", - "ananke_shutdown_triggers_total 1", - "ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - "ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - "ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - "ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - "ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - "ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", - } - for _, m := range mustContain { - if !strings.Contains(body, m) { - t.Fatalf("missing metric fragment %q in output:\n%s", m, body) - } - } -} diff --git a/internal/metrics/testing_hooks.go b/internal/metrics/testing_hooks.go new file mode 100644 index 0000000..df9a727 --- /dev/null +++ b/internal/metrics/testing_hooks.go @@ -0,0 +1,17 @@ +package metrics + +// TestHookBoolNum runs one orchestration or CLI step. +// Signature: TestHookBoolNum(v bool) int. +// Why: top-level testing module coverage still needs explicit access to the +// boolean-to-metric helper without keeping package-local root tests around. +func TestHookBoolNum(v bool) int { + return boolNum(v) +} + +// TestHookSafeLabelValue runs one orchestration or CLI step. +// Signature: TestHookSafeLabelValue(in string) string. +// Why: top-level testing module coverage still needs to verify label escaping +// behavior directly while split-module migration removes in-tree tests. +func TestHookSafeLabelValue(in string) string { + return safe(in) +} diff --git a/testing/coverage/coverage_test.go b/testing/coverage/coverage_test.go index 76e9be7..c9f4844 100644 --- a/testing/coverage/coverage_test.go +++ b/testing/coverage/coverage_test.go @@ -117,6 +117,8 @@ func TestPerFileCoverageReport(t *testing.T) { t, filepath.Join(root, "testing"), testingCover, + "./execx", + "./metrics", "./hygiene", "./orchestrator", "./state", diff --git a/testing/execx/runner_command_contract_test.go b/testing/execx/runner_command_contract_test.go new file mode 100644 index 0000000..2e811e2 --- /dev/null +++ b/testing/execx/runner_command_contract_test.go @@ -0,0 +1,66 @@ +package execxquality + +import ( + "context" + "strings" + "testing" + + "scm.bstein.dev/bstein/ananke/internal/execx" +) + +// TestRunnerRunSuccess runs one orchestration or CLI step. +// Signature: TestRunnerRunSuccess(t *testing.T). +// Why: keeps command execution success behavior stable after moving runner +// checks into the top-level testing module. +func TestRunnerRunSuccess(t *testing.T) { + runner := &execx.Runner{} + out, err := runner.Run(context.Background(), "sh", "-c", "printf ok") + if err != nil { + t.Fatalf("expected command success: %v", err) + } + if out != "ok" { + t.Fatalf("expected output ok, got %q", out) + } +} + +// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step. +// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T). +// Why: preserves failure-output behavior checks after split-module migration. +func TestRunnerRunFailureIncludesOutput(t *testing.T) { + runner := &execx.Runner{} + out, err := runner.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1") + if err == nil { + t.Fatalf("expected command failure") + } + if strings.TrimSpace(out) != "boom" { + t.Fatalf("expected stderr to be preserved, got %q", out) + } +} + +// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step. +// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T). +// Why: keeps the empty-output failure branch covered after root-module tests are removed. +func TestRunnerRunFailureWithoutOutput(t *testing.T) { + runner := &execx.Runner{} + out, err := runner.Run(context.Background(), "sh", "-c", "exit 3") + if err == nil { + t.Fatalf("expected failure") + } + if out != "" { + t.Fatalf("expected empty output, got %q", out) + } +} + +// TestRunnerCommandDiscovery runs one orchestration or CLI step. +// Signature: TestRunnerCommandDiscovery(t *testing.T). +// Why: preserves both positive and negative command existence checks from the +// top-level testing module. +func TestRunnerCommandDiscovery(t *testing.T) { + runner := &execx.Runner{} + if !runner.CommandExists("sh") { + t.Fatalf("expected shell command to exist") + } + if runner.CommandExists("definitely-not-a-real-command-ananke") { + t.Fatalf("expected missing command to be false") + } +} diff --git a/testing/execx/runner_environment_contract_test.go b/testing/execx/runner_environment_contract_test.go new file mode 100644 index 0000000..130bb83 --- /dev/null +++ b/testing/execx/runner_environment_contract_test.go @@ -0,0 +1,54 @@ +package execxquality + +import ( + "bytes" + "context" + "log" + "strings" + "testing" + + "scm.bstein.dev/bstein/ananke/internal/execx" +) + +// TestRunnerDryRun runs one orchestration or CLI step. +// Signature: TestRunnerDryRun(t *testing.T). +// Why: keeps dry-run logging behavior explicit after moving runner tests into +// the top-level testing module. +func TestRunnerDryRun(t *testing.T) { + var buf bytes.Buffer + runner := &execx.Runner{ + DryRun: true, + Logger: log.New(&buf, "", 0), + } + out, err := runner.Run(context.Background(), "echo", "hello") + if err != nil { + t.Fatalf("dry-run should not fail: %v", err) + } + if out != "" { + t.Fatalf("expected empty dry-run output, got %q", out) + } + if !strings.Contains(buf.String(), "DRY-RUN: echo hello") { + t.Fatalf("expected dry-run log entry, got %q", buf.String()) + } +} + +// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step. +// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T). +// Why: keeps kubeconfig environment propagation covered from the split testing module. +func TestRunnerInjectsKubeconfigEnv(t *testing.T) { + runner := &execx.Runner{Kubeconfig: "/tmp/test-kubeconfig"} + out, err := runner.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"") + if err != nil { + t.Fatalf("runner command failed: %v", err) + } + if strings.TrimSpace(out) != "/tmp/test-kubeconfig" { + t.Fatalf("expected kubeconfig env to propagate, got %q", out) + } +} + +// TestRunnerNilLoggerHook runs one orchestration or CLI step. +// Signature: TestRunnerNilLoggerHook(t *testing.T). +// Why: keeps the nil-logger no-op branch covered without reintroducing same-package tests. +func TestRunnerNilLoggerHook(t *testing.T) { + execx.TestHookLogf(&execx.Runner{}, "hello %s", "world") +} diff --git a/testing/hygiene/in_tree_test_allowlist.txt b/testing/hygiene/in_tree_test_allowlist.txt index 4e240b8..e3c8414 100644 --- a/testing/hygiene/in_tree_test_allowlist.txt +++ b/testing/hygiene/in_tree_test_allowlist.txt @@ -19,10 +19,6 @@ internal/cluster/orchestrator_vault_test.go internal/config/config_test.go internal/config/load_additional_test.go internal/config/validate_matrix_test.go -internal/execx/runner_additional_test.go -internal/execx/runner_test.go -internal/metrics/exporter_additional_test.go -internal/metrics/exporter_test.go internal/service/daemon_additional_test.go internal/service/daemon_coverage_closeout_test.go internal/service/daemon_quality_branches_test.go diff --git a/testing/metrics/exporter_http_contract_test.go b/testing/metrics/exporter_http_contract_test.go new file mode 100644 index 0000000..b9d6d1e --- /dev/null +++ b/testing/metrics/exporter_http_contract_test.go @@ -0,0 +1,106 @@ +package metricsquality + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "scm.bstein.dev/bstein/ananke/internal/metrics" +) + +// TestExporterEmitsCoreMetrics runs one orchestration or CLI step. +// Signature: TestExporterEmitsCoreMetrics(t *testing.T). +// Why: keeps exporter metric output stable after moving checks into the +// top-level testing module. +func TestExporterEmitsCoreMetrics(t *testing.T) { + exporter := metrics.New() + exporter.UpdateBudget(321) + exporter.UpdateSample(metrics.Sample{ + Name: "Pyrphoros", + Target: "pyrphoros@localhost", + OnBattery: true, + LowBattery: false, + RuntimeSecond: 412, + BatteryCharge: 81.5, + LoadPercent: 27.4, + PowerNominalW: 510, + ThresholdSec: 354, + Trigger: true, + BreachCount: 2, + Status: "OB", + UpdatedAt: time.Unix(1710000000, 0).UTC(), + }) + exporter.MarkShutdown("ups-threshold") + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rr := httptest.NewRecorder() + exporter.Handler("/metrics").ServeHTTP(rr, req) + body := rr.Body.String() + + mustContain := []string{ + "ananke_shutdown_budget_seconds 321", + "ananke_shutdown_triggers_total 1", + "ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + "ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + "ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + "ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + "ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + "ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"", + } + for _, fragment := range mustContain { + if !strings.Contains(body, fragment) { + t.Fatalf("missing metric fragment %q in output:\n%s", fragment, body) + } + } +} + +// TestExporterHealthzAndEscaping runs one orchestration or CLI step. +// Signature: TestExporterHealthzAndEscaping(t *testing.T). +// Why: covers exporter health routing and label escaping from the top-level +// testing module so HTTP behavior stays explicit after migration. +func TestExporterHealthzAndEscaping(t *testing.T) { + exporter := metrics.New() + exporter.UpdateSample(metrics.Sample{ + Name: `Sta"tera`, + Target: `statera\host`, + Status: `O"B`, + LastError: "x", + }) + + handler := exporter.Handler("/custom") + healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil) + healthRR := httptest.NewRecorder() + handler.ServeHTTP(healthRR, healthReq) + if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" { + t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String()) + } + + metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil) + metricsRR := httptest.NewRecorder() + handler.ServeHTTP(metricsRR, metricsReq) + body := metricsRR.Body.String() + if !strings.Contains(body, `source="Sta\\\"tera"`) { + t.Fatalf("expected escaped source label, got:\n%s", body) + } + if !strings.Contains(body, `target="statera\\\\host"`) { + t.Fatalf("expected escaped target label, got:\n%s", body) + } + if !strings.Contains(body, "ananke_ups_error") { + t.Fatalf("expected error metric line in output") + } +} + +// TestExporterHelperContracts runs one orchestration or CLI step. +// Signature: TestExporterHelperContracts(t *testing.T). +// Why: preserves direct helper coverage for metric formatting utilities after +// removing same-package root tests. +func TestExporterHelperContracts(t *testing.T) { + if metrics.TestHookBoolNum(true) != 1 || metrics.TestHookBoolNum(false) != 0 { + t.Fatalf("unexpected boolNum values") + } + if got := metrics.TestHookSafeLabelValue(`a"b\c`); got != `a\"b\\c` { + t.Fatalf("unexpected escaped string: %q", got) + } +} diff --git a/testing/metrics/exporter_quality_gate_metrics_test.go b/testing/metrics/exporter_quality_gate_metrics_test.go new file mode 100644 index 0000000..bcb55ff --- /dev/null +++ b/testing/metrics/exporter_quality_gate_metrics_test.go @@ -0,0 +1,60 @@ +package metricsquality + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "scm.bstein.dev/bstein/ananke/internal/metrics" +) + +// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step. +// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T). +// Why: verifies exporter output still includes host quality-gate metrics after +// the split-module migration. +func TestExporterAppendsQualityGateMetrics(t *testing.T) { + tmp := t.TempDir() + metricsPath := filepath.Join(tmp, "quality-gate.prom") + content := strings.Join([]string{ + `# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`, + `# TYPE ananke_quality_gate_runs_total counter`, + `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`, + `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`, + "", + }, "\n") + if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil { + t.Fatalf("write quality metrics file: %v", err) + } + t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath) + + exporter := metrics.New() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rr := httptest.NewRecorder() + exporter.Handler("/metrics").ServeHTTP(rr, req) + body := rr.Body.String() + if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) { + t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body) + } +} + +// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step. +// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T). +// Why: keeps zero-value quality-gate series visible before the host writes its +// first metrics file. +func TestExporterDefaultsQualityGateMetrics(t *testing.T) { + t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom")) + exporter := metrics.New() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rr := httptest.NewRecorder() + exporter.Handler("/metrics").ServeHTTP(rr, req) + body := rr.Body.String() + if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) { + t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body) + } + if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) { + t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body) + } +}