test: починить тесты на Windows
- E2E (app): e2eFakeAPI переведён на v2 HTTP API opencode (/api/*) с определением агента по тексту промпта; Router получает processed-счётчик и WaitProcessed, e2eChannel.deliver ждёт асинхронную обработку — убирает гонку «запрос сразу после deliver» и коллатеральный 'database is closed'. - app_test: одинарные YAML-кавычки для путей Windows (backslash-escape) + закрытие Store в TestNew/TestNew_RunCtxCancel/TestNew_UpdateWiring. - config_test: абсолютный путь строится с корнем тома (C:\...) и одинарными кавычками YAML. - opencode/server_test: fakeServeBin на Windows — .cmd с ping (#!/bin/sh не исполняется). - worker_test: TestWorkerSemaphore поллит до целевого статуса вместо фиксированных sleep (git на Windows медленнее).
This commit is contained in:
@@ -36,22 +36,35 @@ import (
|
||||
)
|
||||
|
||||
// вердикты фейкового агента по имени.
|
||||
var (
|
||||
e2eAgentVerdicts = map[string]string{
|
||||
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||
"dev": `done`,
|
||||
"reviewer": `{"passed":true,"comments":[]}`,
|
||||
}
|
||||
)
|
||||
var e2eAgentVerdicts = map[string]string{
|
||||
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||
"dev": `done`,
|
||||
"reviewer": `{"passed":true,"comments":[]}`,
|
||||
}
|
||||
|
||||
// e2eFakeAPI поднимает фейковый opencode serve experimental HTTP API (пути
|
||||
// БЕЗ /api) и возвращает URL. По title сессии (ratatoskr-<agent>) определяет
|
||||
// агента и возвращает его вердикт как text-часть ответа на POST /message.
|
||||
// e2eFakeAPI поднимает фейковый opencode serve, эмулирующий v2 HTTP API
|
||||
// (пути с префиксом /api/*, см. Client в internal/opencode). Агент
|
||||
// (analyst/dev/reviewer) определяется по тексту промпта на POST
|
||||
// /api/session/{id}/prompt; вердикт возвращается как text-часть завершённого
|
||||
// assistant-сообщения, которое отдаёт GET /api/session/{id}/message.
|
||||
func e2eFakeAPI(t *testing.T) string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
sessions := map[string]string{} // id → agent
|
||||
|
||||
agentOf := func(prompt string) string {
|
||||
switch {
|
||||
case strings.Contains(prompt, "Ты — аналитик"):
|
||||
return "analyst"
|
||||
case strings.Contains(prompt, "Ты — dev-агент"):
|
||||
return "dev"
|
||||
case strings.Contains(prompt, "Ты — ревьюер"):
|
||||
return "reviewer"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
verdictFor := func(agent string) string {
|
||||
if v, ok := e2eAgentVerdicts[agent]; ok {
|
||||
return v
|
||||
@@ -59,39 +72,59 @@ func e2eFakeAPI(t *testing.T) string {
|
||||
return "unknown agent"
|
||||
}
|
||||
|
||||
sessionID := func(path, suffix string) string {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(path, "/api/session/"), suffix)
|
||||
}
|
||||
|
||||
assistantMsg := func(id, agent string) map[string]any {
|
||||
ts := time.Now().UnixMilli()
|
||||
return map[string]any{
|
||||
"id": "m-" + id,
|
||||
"type": "assistant",
|
||||
"content": []map[string]any{{"type": "text", "text": verdictFor(agent)}},
|
||||
"model": map[string]any{"providerID": "test", "id": "m"},
|
||||
"time": map[string]any{"created": ts, "completed": ts},
|
||||
}
|
||||
}
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/session":
|
||||
// v2 create: {model:{...}} → {data:{id}}
|
||||
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
|
||||
mu.Lock()
|
||||
sessions[id] = ""
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": id}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt"):
|
||||
// v2 durable admit: {prompt:{text}} → {data:{id,timeCreated}}
|
||||
id := sessionID(r.URL.Path, "/prompt")
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Prompt struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"prompt"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
agent := strings.TrimPrefix(req.Title, "ratatoskr-")
|
||||
mu.Lock()
|
||||
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
|
||||
sessions[id] = agent
|
||||
sessions[id] = agentOf(req.Prompt.Text)
|
||||
mu.Unlock()
|
||||
// experimental: голая Session, id напрямую.
|
||||
writeJSON(w, map[string]any{"id": id, "agent": agent, "model": map[string]any{"id": "m"}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/message"):
|
||||
// блокирующий ответ: вердикт как text-часть.
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/session/"), "/message")
|
||||
mu.Lock()
|
||||
agent := sessions[id]
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"info": map[string]any{"role": "assistant"}, "parts": []map[string]any{{"type": "text", "text": verdictFor(agent)}}})
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": "p-" + id, "timeCreated": time.Now().UnixMilli()}})
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"):
|
||||
// поллинг прогресса: голый массив [{info, parts}].
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/session/"), "/message")
|
||||
// v2 поллинг: {data:[Session.Message]} (новейшие первыми).
|
||||
id := sessionID(r.URL.Path, "/message")
|
||||
mu.Lock()
|
||||
agent := sessions[id]
|
||||
mu.Unlock()
|
||||
writeJSON(w, []map[string]any{{"info": map[string]any{"role": "assistant"}, "parts": []map[string]any{{"type": "text", "text": verdictFor(agent)}}}})
|
||||
writeJSON(w, map[string]any{"data": []map[string]any{assistantMsg(id, agent)}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/abort"):
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/session/active":
|
||||
// сессий в активных дренажах нет → ответ завершён.
|
||||
writeJSON(w, map[string]any{"data": map[string]any{}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/interrupt"):
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"ok": true}})
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
@@ -143,6 +176,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
CoreCtx: coreCtx,
|
||||
}
|
||||
a.Router = chat.NewRouter(a.handleIncoming)
|
||||
fake.router = a.Router
|
||||
if err := a.Router.Attach(fake); err != nil {
|
||||
t.Fatalf("Attach fake channel: %v", err)
|
||||
}
|
||||
@@ -166,8 +200,9 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
||||
// и доставки входящих через роутер (как реальный канал).
|
||||
type e2eChannel struct {
|
||||
onMsg chat.Handler
|
||||
sent []chat.Message
|
||||
onMsg chat.Handler
|
||||
sent []chat.Message
|
||||
router *chat.Router
|
||||
}
|
||||
|
||||
func (c *e2eChannel) Run(_ context.Context) error { return nil }
|
||||
@@ -184,10 +219,21 @@ func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) erro
|
||||
func (c *e2eChannel) Close() error { return nil }
|
||||
|
||||
// deliver отправляет входящее сообщение через роутер: ставит маршрут
|
||||
// пользователя и вызывает app.handleIncoming (как в проде).
|
||||
// пользователя и вызывает app.handleIncoming (как в проде). Так как роутер
|
||||
// обрабатывает входящие асинхронно (процессор-горутина), deliver ждёт, пока
|
||||
// обработка события завершится, — иначе тесты (сразу читающие состояние БД)
|
||||
// гоняются с обработчиком.
|
||||
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
|
||||
if c.onMsg != nil {
|
||||
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
|
||||
if c.onMsg == nil {
|
||||
return
|
||||
}
|
||||
target := int64(0)
|
||||
if c.router != nil {
|
||||
target = c.router.Processed() + 1
|
||||
}
|
||||
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
|
||||
if c.router != nil && !c.router.WaitProcessed(target) {
|
||||
panic("e2e: роутер не обработал входящее за 30s")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user