Files
multica/server/internal/cli/config_test.go
Chenyu-24601 8b94764c47 feat(daemon): configurable OpenClaw binary path / state dir via CLIConfig.Backends (MUL-3157)
Summary:
- Add CLI config schema for OpenClaw backend binary path and state dir overrides.
- Apply those overrides during daemon LoadConfig using the existing env-var based probe/spawn path.
- Cover backward compatibility, precedence, partial overrides, and fail-soft config loading.

Verification:
- go test ./internal/cli ./internal/daemon
- go vet ./internal/cli ./internal/daemon
- GitHub CI passed
2026-06-09 14:05:37 +08:00

214 lines
6.7 KiB
Go

package cli
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// TestCLIConfig_BackwardCompat_OldFileLoadsWithNilBackends verifies that a
// config.json written by an older daemon (no `backends` key at all) loads
// correctly into the new schema, with Backends == nil. This is the most
// important guarantee of issue #3875's PR: existing on-disk configs MUST
// continue to work byte-for-byte.
func TestCLIConfig_BackwardCompat_OldFileLoadsWithNilBackends(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
// Write a 4-field config exactly as the historical daemon would have.
cfgDir := filepath.Join(tmp, ".multica")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
historical := `{
"server_url": "https://api.multica.ai",
"app_url": "https://app.multica.ai",
"workspace_id": "ws-123",
"token": "mul_abcdef"
}`
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(historical), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig on historical file: %v", err)
}
if cfg.ServerURL != "https://api.multica.ai" {
t.Errorf("ServerURL: got %q, want historical value", cfg.ServerURL)
}
if cfg.Token != "mul_abcdef" {
t.Errorf("Token: got %q, want historical value", cfg.Token)
}
if cfg.Backends != nil {
t.Errorf("Backends should be nil for historical config, got %+v", cfg.Backends)
}
}
// TestCLIConfig_BackwardCompat_NilBackendsOmittedFromJSON verifies that
// saving a config without backend overrides does NOT add a `backends` key
// to the on-disk JSON. This matters for users who never set overrides —
// their config files must stay byte-identical, so a future downgrade to
// an older daemon doesn't trip on an empty `backends: null` line.
func TestCLIConfig_BackwardCompat_NilBackendsOmittedFromJSON(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfg := CLIConfig{
ServerURL: "https://api.multica.ai",
Token: "mul_xyz",
}
if err := SaveCLIConfig(cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
if err != nil {
t.Fatal(err)
}
if string(data) == "" {
t.Fatal("config file is empty")
}
// The omitempty tag on Backends should keep it out of the JSON entirely.
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal saved config: %v", err)
}
if _, ok := raw["backends"]; ok {
t.Errorf("backends key should be omitted when nil, got: %s", string(data))
}
}
// TestCLIConfig_OpenClawOverride_RoundTrip verifies that setting BinaryPath
// and StateDir survives a save/load cycle.
func TestCLIConfig_OpenClawOverride_RoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
original := CLIConfig{
ServerURL: "https://api.multica.ai",
Token: "mul_xyz",
Backends: &BackendOverrides{
OpenClaw: &OpenClawOverride{
BinaryPath: "/opt/openclaw-prod/bin/openclaw",
StateDir: "/var/lib/openclaw-prod",
},
},
}
if err := SaveCLIConfig(original); err != nil {
t.Fatal(err)
}
loaded, err := LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
if loaded.Backends == nil || loaded.Backends.OpenClaw == nil {
t.Fatalf("Backends.OpenClaw should be non-nil after round-trip, got %+v", loaded.Backends)
}
if loaded.Backends.OpenClaw.BinaryPath != original.Backends.OpenClaw.BinaryPath {
t.Errorf("BinaryPath round-trip: got %q, want %q",
loaded.Backends.OpenClaw.BinaryPath, original.Backends.OpenClaw.BinaryPath)
}
if loaded.Backends.OpenClaw.StateDir != original.Backends.OpenClaw.StateDir {
t.Errorf("StateDir round-trip: got %q, want %q",
loaded.Backends.OpenClaw.StateDir, original.Backends.OpenClaw.StateDir)
}
}
// TestCLIConfig_OpenClawOverride_PartialFieldsOmitted verifies that an
// override with only one field set does not emit empty strings for the
// unset field. Important so users can intentionally set only BinaryPath
// (or only StateDir) and have the other follow the historical default,
// without an empty string overriding env-var precedence.
func TestCLIConfig_OpenClawOverride_PartialFieldsOmitted(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfg := CLIConfig{
ServerURL: "https://api.multica.ai",
Token: "mul_xyz",
Backends: &BackendOverrides{
OpenClaw: &OpenClawOverride{
StateDir: "/var/lib/openclaw-prod",
// BinaryPath intentionally left empty
},
},
}
if err := SaveCLIConfig(cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
openclaw, ok := raw["backends"].(map[string]any)["openclaw"].(map[string]any)
if !ok {
t.Fatalf("could not navigate to backends.openclaw in: %s", string(data))
}
if _, present := openclaw["binary_path"]; present {
t.Errorf("binary_path should be omitted when empty, got: %s", string(data))
}
if _, present := openclaw["state_dir"]; !present {
t.Errorf("state_dir should be present when set, got: %s", string(data))
}
}
// TestCLIConfig_UnknownFieldsArePreserved verifies forward-compat: a future
// daemon that adds, say, a `backends.codex` key should not have its data
// destroyed when an older daemon (without knowledge of that key) reads and
// re-saves the file. Today Go's encoding/json silently DROPS unknown fields
// on round-trip. This test documents the gap so future maintainers know.
//
// Skipped today (encoding/json does not preserve unknown fields), but the
// test is written so a future change to a preserve-unknown encoder
// (json.RawMessage, mapstructure, etc.) will pick it up.
func TestCLIConfig_UnknownFieldsArePreserved(t *testing.T) {
t.Skip("documenting known limitation: encoding/json drops unknown fields on round-trip; future PR can switch to a preserving encoder")
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfgDir := filepath.Join(tmp, ".multica")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
withFutureField := `{
"server_url": "https://api.multica.ai",
"token": "mul_xyz",
"backends": {
"openclaw": {"state_dir": "/x"},
"future_backend_xyz": {"some_setting": "preserve me"}
}
}`
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(withFutureField), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
if err := SaveCLIConfig(cfg); err != nil {
t.Fatal(err)
}
// After round-trip, future_backend_xyz should still be in the file.
data, _ := os.ReadFile(filepath.Join(cfgDir, "config.json"))
if !strings.Contains(string(data), "future_backend_xyz") {
t.Error("unknown field future_backend_xyz was dropped on round-trip")
}
}