241 lines
8.4 KiB
Go
241 lines
8.4 KiB
Go
package runtime
|
|
|
|
// Tests for the layered configuration system (US-023, #42): the precedence
|
|
// order (default < global < project < env/CLI), per-provider credential merge,
|
|
// and the hard-error paths for malformed files and invalid field values.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/hooks"
|
|
)
|
|
|
|
// ptr is a helper for building pointer-valued config-layer fields in tests.
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// TestResolveConfigPrecedence is the acceptance-critical test: a field set in a
|
|
// higher layer overrides the same field in every lower layer, and the winner is
|
|
// always the highest layer that set it.
|
|
func TestResolveConfigPrecedence(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
global := &ConfigLayer{Model: ptr("global/model"), ThinkingLevel: ptr("low")}
|
|
project := &ConfigLayer{Model: ptr("project/model")}
|
|
env := &ConfigLayer{Model: ptr("env/model"), Provider: ptr("bedrock")}
|
|
|
|
cfg, err := ResolveConfig(&def, global, project, env)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConfig: %v", err)
|
|
}
|
|
// Model set in all four layers → env (highest) wins.
|
|
if cfg.Model != "env/model" {
|
|
t.Errorf("Model = %q, want env/model (highest layer wins)", cfg.Model)
|
|
}
|
|
// Provider set only in env → env value.
|
|
if cfg.Provider != "bedrock" {
|
|
t.Errorf("Provider = %q, want bedrock", cfg.Provider)
|
|
}
|
|
// ThinkingLevel set in global only (not project/env) → global value shows through.
|
|
if cfg.ThinkingLevel != agentcore.ThinkingLow {
|
|
t.Errorf("ThinkingLevel = %q, want low (from global, lower layers don't set it)", cfg.ThinkingLevel)
|
|
}
|
|
// ToolExecutionMode set only in default → default shows through.
|
|
if cfg.ToolExecutionMode != agentcore.ToolExecutionParallel {
|
|
t.Errorf("ToolExecutionMode = %q, want parallel (default)", cfg.ToolExecutionMode)
|
|
}
|
|
}
|
|
|
|
// TestResolveConfigCredentialMerge verifies credentials merge per-provider: a
|
|
// higher layer overrides one provider's key while a lower layer's other-provider
|
|
// key is retained.
|
|
func TestResolveConfigCredentialMerge(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
global := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-low", "ollama": "ol-key"}}
|
|
project := &ConfigLayer{Credentials: map[string]string{"openrouter": "or-high"}}
|
|
|
|
cfg, err := ResolveConfig(&def, global, project)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConfig: %v", err)
|
|
}
|
|
if cfg.Credentials["openrouter"] != "or-high" {
|
|
t.Errorf("openrouter key = %q, want or-high (project overrides global)", cfg.Credentials["openrouter"])
|
|
}
|
|
if cfg.Credentials["ollama"] != "ol-key" {
|
|
t.Errorf("ollama key = %q, want ol-key (retained from global)", cfg.Credentials["ollama"])
|
|
}
|
|
}
|
|
|
|
// TestResolveConfigInvalidValues verifies invalid field values are hard errors.
|
|
func TestResolveConfigInvalidValues(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
cases := []struct {
|
|
name string
|
|
layer *ConfigLayer
|
|
}{
|
|
{"bad mode", &ConfigLayer{ToolExecutionMode: ptr("concurrent")}},
|
|
{"bad thinking", &ConfigLayer{ThinkingLevel: ptr("ultra")}},
|
|
{"empty model", &ConfigLayer{Model: ptr("")}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := ResolveConfig(&def, tc.layer); err == nil {
|
|
t.Errorf("%s must be a hard error, got nil", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigLayerMissingAndMalformed verifies a missing file is not an error
|
|
// (nil layer) while a malformed file is.
|
|
func TestLoadConfigLayerMissingAndMalformed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Missing file → nil, nil.
|
|
layer, err := LoadConfigLayer(filepath.Join(dir, "nope.json"))
|
|
if err != nil || layer != nil {
|
|
t.Errorf("missing file: got (%v, %v), want (nil, nil)", layer, err)
|
|
}
|
|
|
|
// Malformed file → error.
|
|
bad := filepath.Join(dir, "bad.json")
|
|
if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadConfigLayer(bad); err == nil {
|
|
t.Error("malformed config file must return an error")
|
|
}
|
|
|
|
// Well-formed file → decoded layer.
|
|
good := filepath.Join(dir, "good.json")
|
|
if err := os.WriteFile(good, []byte(`{"model":"m","provider":"p"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
layer, err = LoadConfigLayer(good)
|
|
if err != nil {
|
|
t.Fatalf("good file: %v", err)
|
|
}
|
|
if layer == nil || layer.Model == nil || *layer.Model != "m" {
|
|
t.Errorf("good file decoded incorrectly: %+v", layer)
|
|
}
|
|
}
|
|
|
|
// TestEnvConfigLayer verifies env vars map to the right fields and unset vars
|
|
// leave fields nil.
|
|
func TestEnvConfigLayer(t *testing.T) {
|
|
env := map[string]string{
|
|
"PIGO_MODEL": "env/m",
|
|
"PIGO_THINKING_LEVEL": "high",
|
|
"PIGO_TOOL_EXECUTION_MODE": "sequential",
|
|
}
|
|
layer := EnvConfigLayer(func(k string) string { return env[k] })
|
|
if layer.Model == nil || *layer.Model != "env/m" {
|
|
t.Errorf("PIGO_MODEL not captured: %+v", layer.Model)
|
|
}
|
|
if layer.ThinkingLevel == nil || *layer.ThinkingLevel != "high" {
|
|
t.Errorf("PIGO_THINKING_LEVEL not captured: %+v", layer.ThinkingLevel)
|
|
}
|
|
if layer.ToolExecutionMode == nil || *layer.ToolExecutionMode != "sequential" {
|
|
t.Errorf("PIGO_TOOL_EXECUTION_MODE not captured: %+v", layer.ToolExecutionMode)
|
|
}
|
|
// Unset var → nil field.
|
|
if layer.Provider != nil {
|
|
t.Errorf("unset PIGO_PROVIDER should leave Provider nil, got %v", *layer.Provider)
|
|
}
|
|
}
|
|
|
|
// TestResolveConfigDefaultsAlone verifies the default layer alone yields a valid
|
|
// config.
|
|
func TestResolveConfigDefaultsAlone(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
cfg, err := ResolveConfig(&def)
|
|
if err != nil {
|
|
t.Fatalf("default-only config must be valid: %v", err)
|
|
}
|
|
if cfg.Model == "" || cfg.ToolExecutionMode == "" || cfg.ThinkingLevel == "" {
|
|
t.Errorf("default config incomplete: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
// TestResolveConfigHooksAppendMerge verifies hooks are append-merged per event
|
|
// type across layers (FR-2) in ascending order, not overridden like scalars.
|
|
func TestResolveConfigHooksAppendMerge(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
global := &ConfigLayer{Hooks: hooks.HookSet{
|
|
"PreToolUse": {{Matcher: "*", Hooks: []hooks.HookConfig{{Command: "global-pre"}}}},
|
|
"Stop": {{Matcher: "", Hooks: []hooks.HookConfig{{Command: "global-stop"}}}},
|
|
}}
|
|
project := &ConfigLayer{Hooks: hooks.HookSet{
|
|
"PreToolUse": {{Matcher: "bash", Hooks: []hooks.HookConfig{{Command: "project-pre"}}}},
|
|
}}
|
|
|
|
cfg, err := ResolveConfig(&def, global, project)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConfig: %v", err)
|
|
}
|
|
pre := cfg.Hooks["PreToolUse"]
|
|
if len(pre) != 2 {
|
|
t.Fatalf("PreToolUse matchers = %d, want 2 (global+project appended)", len(pre))
|
|
}
|
|
// Ascending layer order: global before project.
|
|
if pre[0].Hooks[0].Command != "global-pre" || pre[1].Hooks[0].Command != "project-pre" {
|
|
t.Errorf("PreToolUse order wrong: %q, %q", pre[0].Hooks[0].Command, pre[1].Hooks[0].Command)
|
|
}
|
|
if len(cfg.Hooks["Stop"]) != 1 {
|
|
t.Errorf("Stop matchers = %d, want 1 (only global set it)", len(cfg.Hooks["Stop"]))
|
|
}
|
|
}
|
|
|
|
// TestResolveConfigHooksNilWhenAbsent verifies the no-hooks path leaves
|
|
// cfg.Hooks nil (FR-18), so downstream can cheaply skip hook dispatch.
|
|
func TestResolveConfigHooksNilWhenAbsent(t *testing.T) {
|
|
def := DefaultConfigLayer()
|
|
cfg, err := ResolveConfig(&def)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConfig: %v", err)
|
|
}
|
|
if cfg.Hooks != nil {
|
|
t.Errorf("Hooks = %v, want nil when no layer defines hooks", cfg.Hooks)
|
|
}
|
|
// An empty (but non-nil) hook map in a layer must not allocate cfg.Hooks.
|
|
empty := &ConfigLayer{Hooks: hooks.HookSet{"PreToolUse": {}}}
|
|
cfg2, err := ResolveConfig(&def, empty)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConfig: %v", err)
|
|
}
|
|
if cfg2.Hooks != nil {
|
|
t.Errorf("Hooks = %v, want nil when layer's event has no matchers", cfg2.Hooks)
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigLayerHooks verifies a layer's hooks decode from JSON, including
|
|
// per-hook timeout and matcher fields.
|
|
func TestLoadConfigLayerHooks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.json")
|
|
body := `{
|
|
"model": "x/y",
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{"matcher": "bash", "hooks": [{"type": "command", "command": "echo hi", "timeout": 5}]}
|
|
]
|
|
}
|
|
}`
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
layer, err := LoadConfigLayer(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfigLayer: %v", err)
|
|
}
|
|
pre := layer.Hooks["PreToolUse"]
|
|
if len(pre) != 1 || pre[0].Matcher != "bash" {
|
|
t.Fatalf("unexpected matchers: %+v", pre)
|
|
}
|
|
h := pre[0].Hooks[0]
|
|
if h.Command != "echo hi" || h.TimeoutSeconds() != 5 {
|
|
t.Errorf("unexpected hook: cmd=%q timeout=%d", h.Command, h.TimeoutSeconds())
|
|
}
|
|
}
|