OPENCODE_CONFIG_DIR в opencode v1.18.18 перенаправляет Global.Path.config (global.ts: config = OPENCODE_CONFIG_DIR ?? ~/.config/opencode), из-за чего глобальный конфиг (модель/провайдеры, напр. tokentool) не загружался и opencode уходил в fallback-модель. Агенты открывались, т.к. OPENCODE_CONFIG_DIR дополнительно сканируется как каталог для agent/*.md. Теперь агенты распаковываются в <worktree>/.opencode/agent/*.md, где opencode находит их через project-каталог .opencode (paths.ts, cwd=worktree). Глобальный конфиг не трогаем вовсе. - internal/opencode: удалены Server.ConfigDir/Pool.ConfigDir и env OPENCODE_CONFIG_DIR - internal/config: удалено поле OpenCodeCfg.ConfigDir (config_dir) - internal/app: ensureAgentsDir пишет в <worktree>/.opencode/agent - internal/agents: WriteTo(dir) → dir/agent/*.md - README/config.yaml.example/memory обновлены
323 lines
8.0 KiB
Go
323 lines
8.0 KiB
Go
package config
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func writeCfg(t *testing.T, content string) string {
|
||
t.Helper()
|
||
f, err := os.CreateTemp(t.TempDir(), "cfg*.yaml")
|
||
if err != nil {
|
||
t.Fatalf("temp: %v", err)
|
||
}
|
||
if _, err := f.WriteString(content); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
f.Close()
|
||
return f.Name()
|
||
}
|
||
|
||
func TestLoad_FullConfig(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok123")
|
||
|
||
yaml := `
|
||
telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "123"
|
||
|
||
opencode:
|
||
bin: "my-opencode"
|
||
hard_timeout: "10m"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.Telegram.Token != "tok123" {
|
||
t.Errorf("tg token = %q", cfg.Telegram.Token)
|
||
}
|
||
if cfg.Telegram.ChatID != "123" {
|
||
t.Errorf("tg chat_id = %q", cfg.Telegram.ChatID)
|
||
}
|
||
if cfg.OpenCode.Bin != "my-opencode" {
|
||
t.Errorf("opencode bin = %q", cfg.OpenCode.Bin)
|
||
}
|
||
if cfg.OpenCode.HardTimeout.Duration() != 10*time.Minute {
|
||
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
||
}
|
||
}
|
||
|
||
func TestLoad_Defaults(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `
|
||
telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
// default opencode.bin
|
||
if cfg.OpenCode.Bin != "opencode" {
|
||
t.Errorf("bin = %q, want opencode", cfg.OpenCode.Bin)
|
||
}
|
||
if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute {
|
||
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
||
}
|
||
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
||
}
|
||
if cfg.OpenCode.Serve.Enabled {
|
||
t.Errorf("serve.enabled = true, want false (дефолт)")
|
||
}
|
||
if cfg.OpenCode.Serve.Hostname != "127.0.0.1" {
|
||
t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname)
|
||
}
|
||
if cfg.OpenCode.Serve.Port != 4096 {
|
||
t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port)
|
||
}
|
||
if cfg.Paths.Worktree != "./worktrees" {
|
||
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
|
||
}
|
||
}
|
||
|
||
func TestLoad_LogLevelDefault(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
`
|
||
cfg, err := Load(writeCfg(t, yaml))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.Log.Level != "info" {
|
||
t.Errorf("log.level = %q, want info (дефолт)", cfg.Log.Level)
|
||
}
|
||
if cfg.Log.Debug() {
|
||
t.Errorf("Debug() = true при уровне info, want false")
|
||
}
|
||
}
|
||
|
||
func TestLoad_LogLevelDebug(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
log:
|
||
level: debug
|
||
`
|
||
cfg, err := Load(writeCfg(t, yaml))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if !cfg.Log.Debug() {
|
||
t.Errorf("Debug() = false при level=debug, want true")
|
||
}
|
||
}
|
||
|
||
func TestLoad_LogLevelInvalid(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
log:
|
||
level: warn
|
||
`
|
||
_, err := Load(writeCfg(t, yaml))
|
||
if !errors.Is(err, ErrInvalidFormat) {
|
||
t.Fatalf("Load: err = %v, want ErrInvalidFormat", err)
|
||
}
|
||
}
|
||
|
||
func TestLoad_EnvOverride(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
// env override для opencode.timeout
|
||
t.Setenv("OPENCODE_CONFIG", "/custom/opencode.json")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.OpenCode.Config != "/custom/opencode.json" {
|
||
t.Errorf("opencode config = %q", cfg.OpenCode.Config)
|
||
}
|
||
}
|
||
|
||
func TestLoad_MissingEnvVarUsesDefault(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
opencode:
|
||
db_path: "${OPENCODE_DB:-/default/db.sqlite}"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.OpenCode.DBPath != "/default/db.sqlite" {
|
||
t.Errorf("db_path = %q, want /default/db.sqlite", cfg.OpenCode.DBPath)
|
||
}
|
||
}
|
||
|
||
func TestLoad_FileNotFound(t *testing.T) {
|
||
_, err := Load("/nonexistent/config.yaml")
|
||
if err == nil || !strings.Contains(err.Error(), "file not found") {
|
||
t.Fatalf("ожидалась ошибка file not found, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestResolveExePaths_RelativeToExe(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
token: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
paths:
|
||
db: "./ratatoskr.db"
|
||
worktree: "./worktrees"
|
||
`
|
||
cfg, err := Load(writeCfg(t, yaml))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
cfg.ResolveExePaths()
|
||
|
||
base := ExeDir()
|
||
if cfg.Paths.DB != filepath.Join(base, "ratatoskr.db") {
|
||
t.Errorf("db = %q, want %q", cfg.Paths.DB, filepath.Join(base, "ratatoskr.db"))
|
||
}
|
||
if cfg.Paths.Worktree != filepath.Join(base, "worktrees") {
|
||
t.Errorf("worktree = %q, want %q", cfg.Paths.Worktree, filepath.Join(base, "worktrees"))
|
||
}
|
||
}
|
||
|
||
func TestResolveExePaths_AbsoluteKept(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "tok")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
// абсолютные пути «для текущей ОС»: на Windows слэш-относительный путь
|
||
// (\data\...) НЕ является абсолютным — нужен корень тома (C:\data\...).
|
||
root := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
|
||
absDB := filepath.Join(root, "data", "ratatoskr.db")
|
||
absWt := filepath.Join(root, "worktrees")
|
||
yaml := `telegram:
|
||
username: "${TG_TOKEN}"
|
||
chat_id: "${TG_CHAT_ID}"
|
||
paths:
|
||
db: '` + absDB + `'
|
||
worktree: '` + absWt + `'
|
||
`
|
||
cfg, err := Load(writeCfg(t, yaml))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
cfg.ResolveExePaths()
|
||
if cfg.Paths.DB != absDB {
|
||
t.Errorf("db = %q, want абсолютный путь сохранён %q", cfg.Paths.DB, absDB)
|
||
}
|
||
if cfg.Paths.Worktree != absWt {
|
||
t.Errorf("worktree = %q, want абсолютный путь сохранён %q", cfg.Paths.Worktree, absWt)
|
||
}
|
||
}
|
||
|
||
func TestLoad_MissingRequired(t *testing.T) {
|
||
// пустой конфиг — все обязательные поля отсутствуют (telegram.enabled по дефолту true)
|
||
t.Setenv("TG_TOKEN", "")
|
||
t.Setenv("TG_CHAT_ID", "")
|
||
|
||
yaml := `telegram:
|
||
token: ""
|
||
chat_id: ""
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
_, err := Load(path)
|
||
if err == nil {
|
||
t.Fatal("ожидалась ошибка валидации")
|
||
}
|
||
if !strings.Contains(err.Error(), "telegram.token") {
|
||
t.Errorf("ожидалось telegram.token в ошибке, got %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "telegram.chat_id") {
|
||
t.Errorf("ожидалось telegram.chat_id в ошибке, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestLoad_TelegramDisabledSkipsToken(t *testing.T) {
|
||
// enabled: false — token/chat_id не обязательны, конфиг валиден
|
||
t.Setenv("TG_TOKEN", "")
|
||
t.Setenv("TG_CHAT_ID", "")
|
||
|
||
yaml := `telegram:
|
||
enabled: false
|
||
token: ""
|
||
chat_id: ""
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if cfg.Telegram.Enabled {
|
||
t.Error("telegram.enabled = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestLoad_TelegramEnabledDefaultTrue(t *testing.T) {
|
||
// enabled не задан — дефолт true, token обязателен
|
||
t.Setenv("TG_TOKEN", "")
|
||
t.Setenv("TG_CHAT_ID", "42")
|
||
|
||
yaml := `telegram:
|
||
chat_id: "42"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
_, err := Load(path)
|
||
if err == nil || !strings.Contains(err.Error(), "telegram.token") {
|
||
t.Errorf("ожидалась ошибка telegram.token (enabled по дефолту true), got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestLoad_InvalidDuration(t *testing.T) {
|
||
t.Setenv("TG_TOKEN", "x")
|
||
t.Setenv("TG_CHAT_ID", "x")
|
||
|
||
yaml := `telegram:
|
||
token: "x"
|
||
chat_id: "x"
|
||
opencode:
|
||
hard_timeout: "не-длительность"
|
||
`
|
||
path := writeCfg(t, yaml)
|
||
_, err := Load(path)
|
||
if err == nil || !strings.Contains(err.Error(), "parse duration") {
|
||
t.Fatalf("ожидалась ошибка парсинга duration, got %v", err)
|
||
}
|
||
} |