Files
ratatoskr-go/internal/config/config_test.go
ki.sagidullin 8c91c83024
Some checks failed
CI / test (push) Failing after 1m20s
CI / build-and-package (amd64, linux) (push) Failing after 1m4s
CI / build-and-package (amd64, windows) (push) Successful in 37s
fix(opencode): агенты — в <worktree>/.opencode/agent, OPENCODE_CONFIG_DIR больше не выставляем
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 обновлены
2026-08-23 17:54:35 +05:00

323 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}