diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md index 496f7bb..c2ec5fb 100644 --- a/.serena/memories/conventions.md +++ b/.serena/memories/conventions.md @@ -40,4 +40,15 @@ ## Версии - `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы, minor=новая обратно-совместимая функциональность, major=несовместимые изменения). Сейчас 0.2.2. -- `main.version` (ldflag) — build-идентификатор `commit-`, отдельно от app.Version. \ No newline at end of file +- `main.version` (ldflag) — build-идентификатор `commit-`, отдельно от app.Version. + +## Форматирование (важно на Windows) +- Репо на Windows-чекауте: `core.autocrlf=true` → файлы в рабочей копии с CRLF; `gofmt -l` + на CRLF-копии шумит (глобально ~60 файлов). **Проверять формат только на LF-версии** (напр. + `git -c core.autocrlf=false clone` во временный каталог) — так дифы видны корректно. +- gofmt 1.26 форматирует doc-comments (`//` перед `go build ...` в `// go build` → пустая строка + `//`) и выравнивание структур; не все файлы отформатированы по новой версии (предсуществующе). +- CI (`go vet` + `go test`) формат не проверяет → gofmt-дифы не ломают сборку. +- **Known race в тест-харнессе app:** `e2eChannel.Send` (`internal/app/e2e_test.go:213`) пишет + `c.sent` без лока, тест читает с главной горутины → `-race` ловит в `TestE2ENotificationsOnTransitions`. + Путь `worker→Notify→Send`; предсуществует, ещё не чинили (fix — мьютекс в `e2eChannel`). \ No newline at end of file diff --git a/.serena/memories/core.md b/.serena/memories/core.md index b995955..d684417 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -31,13 +31,15 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do ## Ключевые инварианты - **App.New-сигнатура:** `App.New(configPath, version, updateToken string, noUI bool)` (4-й параметр — headless; cgo-вариант собирается только при наличии C-компилятора). +- **chat.Router:** асинхронная обработка входящих — **ограниченный пул `chatWorkers=4` воркеров + FIFO-очереди per-user** (`userState`, `workerLoop`/`runUser`). Порядок сообщений одного UserID сохраняется (флаг `scheduled` → один активный воркер на пользователя); разные пользователи обрабатываются параллельно (до 4 одновременных LLM-вызовов). Backpressure по `jobs` блокирует только перегруженного пользователя, не весь long-poll. `Processed()`/`WaitProcessed()` — синхронизация тестов. +- **FreeChat** (app): `sessions map[uid]sessionID` защищён `sync.Mutex` (пишется из разных воркеров роутера). - **UI:** окно — ещё одна реализация `chat.Channel` (присоединяется в Router). Core не трогает UI; обмен — событийная шина (events). Кнопка «Завершить» = полный выход (SetOnQuit→cancel→UI.Run возвращается); закрытие крестиком = сворачивание, Core живёт. UI собирается с `--noui`/без cgo. - **Фазы аналитика (Decision.Phase):** `ask`, `propose`, `ready` (два последних обрабатываются одинаково в core), `abort`. Требования валидатора: ask — chat_reply/questions; propose — хотя бы одно изменённое поле; ready — без изменённых полей. - **Статусы задач (internal/model):** draft→collecting→ready→approved→running→success/failed/timeout + cancelled/aborted/closed (терминальные). UserID — chat.ID (одна активная задача на чат). - **Decider/Worker/Analyst/Reviewer:** Decider=analyst интерфейс; Worker — polling-планировщик; Reviewer проверяет diff dev-ветки (R1-R6), вердикт JSON {passed, critical_issues, solid_violations, comments}. - **gitops (worker):** worktree-режим; feature-ветка `feat/` от origin/main; push через http.extraHeader, токен Bearer. - **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd. -- **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-/` (не `latest/`); Verify сверяет предprod-версию (binary+в.в) . +- **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-/` (не `latest/`); Verify сверяет контрольную сумму бинаря против `.sha256` той же версии. **Perf:** `ResolveLatest` проверяет наличие бинаря версии **HEAD-пробой без скачивания тела** (405/501 → fallback `GET Range: bytes=0-0`), сортировка версий по id убыв.; один общий `http.Client` (keep-alive). Ошибка U4 — только при несовпадении суммы (пустой/отсутствующий `.sha256` пропускает проверку — M1-известное замечание). ## opencode (v2 HTTP API, >= 1.18.18) diff --git a/internal/app/app.go b/internal/app/app.go index 7efae7e..51a71ba 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -83,6 +84,7 @@ type App struct { type FreeChat struct { Runner analyst.OpenCodeRunner Worktree string + mu sync.Mutex // защищает sessions (пишется из воркеров роутера) sessions map[chat.UserID]string // uid → opencode sessionID } @@ -95,12 +97,18 @@ func NewFreeChat(runner analyst.OpenCodeRunner, worktree string) *FreeChat { // и возвращает ответ модели. Сессия продолжается (resume по sessionID), поэтому // каждая вкладка ведёт свой независимый диалог. func (f *FreeChat) Chat(ctx context.Context, uid chat.UserID, text string) (string, error) { + f.mu.Lock() sid := f.sessions[uid] + f.mu.Unlock() + res, err := f.Runner.Run(ctx, text, f.Worktree, "chat", sid) if err != nil { return "", err } + + f.mu.Lock() f.sessions[uid] = res.SessionID + f.mu.Unlock() return res.Stdout, nil } diff --git a/internal/chat/router.go b/internal/chat/router.go index 913cd0a..dd9d7c8 100644 --- a/internal/chat/router.go +++ b/internal/chat/router.go @@ -21,36 +21,90 @@ type Router struct { // Hook, вызываемый на каждое входящее событие (обычно → process_turn). onUserMsg func(Incoming) - // Асинхронная обработка входящих: handleIncoming кладёт событие в канал, - // воркер-горутина последовательно вызывает onUserMsg. Благодаря этому - // long-poll цикл канала (Telegram) не блокируется на время долгого - // вызова аналитика и продолжает принимать новые сообщения. - incoming chan Incoming + // Асинхронная обработка входящих ограниченным пулом воркеров с + // упорядоченными очередями per-user (см. chatWorkers, userState). + // Благодаря этому long-poll цикл канала (Telegram) не блокируется на время + // долгого вызова аналитика, а сообщения разных пользователей не сериализуются + // друг за другом: каждый активный пользователь занимает своего воркера. + jobs chan *userState + users map[UserID]*userState + userMu sync.Mutex // processed — число обработанных воркером событий (для синхронизации // тестов с асинхронной очередью: WaitProcessed ждёт обработку события). processed atomic.Int64 } +// userState — FIFO-очередь входящих одного пользователя. В каждый момент +// для пользователя активен ровно один воркер (scheduled), поэтому порядок +// обработки его сообщений сохраняется, а параллелизм достигается между +// разными пользователями. +type userState struct { + mu sync.Mutex + pending []Incoming + scheduled bool +} + +// chatWorkers — число воркеров обработки входящих. Ограничивает количество +// одновременных тяжёлых LLM-вызовов (аналитик/свободный чат), чтобы поток +// каналов не упирался в один долгий вызов. +const chatWorkers = 4 + // NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего. func NewRouter(onUserMsg func(Incoming)) *Router { if onUserMsg == nil { onUserMsg = func(Incoming) {} } r := &Router{ - sessions: map[UserID]any{}, - routes: map[UserID]Route{}, - pending: map[UserID]PendingQ{}, + sessions: map[UserID]any{}, + routes: map[UserID]Route{}, + pending: map[UserID]PendingQ{}, onUserMsg: onUserMsg, - incoming: make(chan Incoming, 256), + jobs: make(chan *userState, chatWorkers), + users: make(map[UserID]*userState), + } + for i := 0; i < chatWorkers; i++ { + go r.workerLoop() } - go r.processLoop() return r } -// processLoop — воркер асинхронной обработки входящих (FIFO). -func (r *Router) processLoop() { - for inc := range r.incoming { +// userStateOf возвращает очередь пользователя (создаёт при первом сообщении). +// Очереди живут вечно — по одной маленькой структуре на пользователя/вкладку. +func (r *Router) userStateOf(uid UserID) *userState { + r.userMu.Lock() + defer r.userMu.Unlock() + st, ok := r.users[uid] + if !ok { + st = &userState{} + r.users[uid] = st + } + return st +} + +// workerLoop — воркер пула: берёт пользователя из общей очереди и дренит его. +func (r *Router) workerLoop() { + for st := range r.jobs { + r.runUser(st) + } +} + +// runUser обрабатывает все накопленные сообщения пользователя по порядку. +// По исчерпании очереди снимает scheduled — следующий handleIncoming вновь +// поставит пользователя в jobs. Возвращается в workerLoop, чтобы тот взял +// следующего пользователя из общей очереди. +func (r *Router) runUser(st *userState) { + for { + st.mu.Lock() + if len(st.pending) == 0 { + st.scheduled = false + st.mu.Unlock() + return + } + inc := st.pending[0] + st.pending = st.pending[1:] + st.mu.Unlock() + r.onUserMsg(inc) r.processed.Add(1) } @@ -99,9 +153,19 @@ func (r *Router) handleIncoming(inc Incoming) { } r.mu.Unlock() - // Асинхронная обработка: кладём событие в очередь воркера и сразу - // возвращаемся, не блокируя вызывающий long-poll цикл канала. - r.incoming <- inc + // Асинхронная обработка: кладём событие в FIFO-очередь пользователя и + // сразу возвращаемся, не блокируя вызывающий long-poll цикл канала. + // Если пользователь ещё не обрабатывается — ставим его в общую очередь + // пула воркеров. Backpressure по jobs блокирует только перегруженного + // пользователя (его собственную горутину канала), не весь роутер. + st := r.userStateOf(inc.UserID) + st.mu.Lock() + st.pending = append(st.pending, inc) + if !st.scheduled { + st.scheduled = true + r.jobs <- st + } + st.mu.Unlock() } // Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op, diff --git a/internal/chat/router_test.go b/internal/chat/router_test.go index 855212f..4cc65c6 100644 --- a/internal/chat/router_test.go +++ b/internal/chat/router_test.go @@ -9,8 +9,8 @@ import ( ) const ( - uidA UserID = "u-a" - uidB UserID = "u-b" + uidA UserID = "u-a" + uidB UserID = "u-b" tg Address = "tg://123" tui Address = "tui://local" ) @@ -213,3 +213,105 @@ func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) { t.Fatal("pending должен остаться (ответ из другого адреса)") } } + +// TestRouter_PerUserOrdering проверяет, что сообщения одного пользователя +// обрабатываются строго в порядке поступления (пул воркеров не перемешивает). +func TestRouter_PerUserOrdering(t *testing.T) { + cb := newFakeOnMsg() + r := NewRouter(cb.h) + tgCh := newFakeChannel(tg) + _ = r.Attach(tgCh) + + for _, txt := range []string{"1", "2", "3"} { + tgCh.emit(uidA, tg, txt) + } + if !cb.wait(3) { + t.Fatal("сообщения не обработаны за таймаут") + } + for i, want := range []string{"1", "2", "3"} { + if got := cb.get(i).Msg.Text; got != want { + t.Errorf("порядок обработки нарушен: idx %d = %q, want %q", i, got, want) + } + } +} + +// TestRouter_ParallelismAcrossUsers проверяет, что пока обработчик одного +// пользователя заблокирован (долгий LLM-вызов), сообщение другого пользователя +// обрабатывается в другом воркере, а второе сообщение того же пользователя — +// ждёт своей очереди (per-user порядок). +func TestRouter_ParallelismAcrossUsers(t *testing.T) { + r := NewRouter(nil) + + tgCh := newFakeChannel(tg) + tuiCh := newFakeChannel(tui) + _ = r.Attach(tgCh) + _ = r.Attach(tuiCh) + + blocked := make(chan struct{}) + release := make(chan struct{}) + var muLocal sync.Mutex + seen := make([]string, 0, 3) + signal := make(chan struct{}, 8) + + h := func(inc Incoming) { + if inc.Msg.Text == "block" { + close(blocked) + <-release // держим воркера, пока не отпустим + } + muLocal.Lock() + seen = append(seen, inc.Msg.Text) + muLocal.Unlock() + signal <- struct{}{} + } + r.onUserMsg = h + + snapshot := func() []string { + muLocal.Lock() + defer muLocal.Unlock() + return append([]string(nil), seen...) + } + waitFor := func(n int) bool { + deadline := time.After(2 * time.Second) + for len(snapshot()) < n { + select { + case <-signal: + case <-deadline: + return false + } + } + return true + } + + // первое сообщение A блокирует своего воркера + tgCh.emit(uidA, tg, "block") + <-blocked + + // B обрабатывается параллельно, пока A висит + tuiCh.emit(uidB, tui, "B1") + select { + case <-signal: + case <-time.After(100 * time.Millisecond): + t.Fatal("B не обработан, пока блокирован A — чат-путь снова сериализован") + } + if got := snapshot(); len(got) != 1 || got[0] != "B1" { + t.Fatalf("ожидали обработку B1, got %v", got) + } + + // второе сообщение A НЕ обрабатывается, пока занят воркер A (порядок per-user) + tgCh.emit(uidA, tg, "a2") + select { + case <-signal: + t.Fatal("сообщение A обработано ДО освобождения A — нарушен per-user порядок") + case <-time.After(80 * time.Millisecond): + } + + // отпускаем A → дообрабатывается a2 + close(release) + if !waitFor(3) { + t.Fatal("итоговые сообщения не обработаны") + } + got := snapshot() + if got[2] != "a2" { + t.Errorf("порядок персональной очереди нарушен: pos2 = %q, want a2", got[2]) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 0aed2aa..593078c 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -31,7 +31,9 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" + "sync" "time" ) @@ -50,6 +52,11 @@ type Updater struct { // Dir — каталог рядом с бинарём (для .new/.old и companion-метаданных). // Ставится app из os.Executable(); если пуст — используется каталог Workdir. Dir string + + // client — общий HTTP-клиент (keep-alive), чтобы проверки/скачивания + // переиспользовали соединения, а не создавали новое на каждый запрос. + client *http.Client + clientMu sync.Mutex } // Result — результат Check. @@ -118,16 +125,26 @@ func (u *Updater) versionsURL() string { return base + "/api/v1/packages/" + url.PathEscape(u.Owner) + "/generic/" + url.PathEscape(u.Package) } +// httpClient возвращает общий клиент (keep-alive), инициализируя лениво. +func (u *Updater) httpClient() *http.Client { + u.clientMu.Lock() + defer u.clientMu.Unlock() + if u.client == nil { + u.client = &http.Client{Timeout: 30 * time.Second} + } + return u.client +} + // httpGet скачивает файл по URL бэкенда. При Token непустом — Basic/токен-заголовок. -func (u *Updater) httpGet(url string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) +func (u *Updater) httpGet(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } if u.Token != "" { req.Header.Set("Authorization", "token "+u.Token) } - resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + resp, err := u.httpClient().Do(req) if err != nil { return nil, err } @@ -138,6 +155,61 @@ func (u *Updater) httpGet(url string) ([]byte, error) { return io.ReadAll(resp.Body) } +// fileExists проверяет наличие файла по URL без скачивания тела: HEAD, +// а при 405/501 (сервер не поддерживает HEAD) — fallback на GET с Range байт 0-0. +// Возвращает (false, nil) при 404/410 — файла нет. +func (u *Updater) fileExists(ctx context.Context, url string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return false, err + } + if u.Token != "" { + req.Header.Set("Authorization", "token "+u.Token) + } + resp, err := u.httpClient().Do(req) + if err != nil { + return false, err + } + resp.Body.Close() + switch { + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: + return false, nil + case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent: + return true, nil + case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented: + // Gitea может не отвечать на HEAD — проверяем GET с Range 0-0 без чтения тела. + return u.fileExistsByRange(ctx, url) + default: + return false, nil + } +} + +// fileExistsByRange проверяет наличие файла GET'ом с Range: bytes=0-0. +// Тело не читается: достаточно лишь первых байт заголовков ответа. +func (u *Updater) fileExistsByRange(ctx context.Context, url string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + if u.Token != "" { + req.Header.Set("Authorization", "token "+u.Token) + } + req.Header.Set("Range", "bytes=0-0") + resp, err := u.httpClient().Do(req) + if err != nil { + return false, err + } + resp.Body.Close() + switch { + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: + return false, nil + case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent: + return true, nil + default: + return false, nil + } +} + // Check определяет, есть ли более свежая версия в Gitea Packages. // Не скачивает бинарь. Ошибка (U1) возвращается в Result.Err — вызывающий // решает, логировать и пропустить. @@ -167,8 +239,12 @@ type pkgVersion struct { // ResolveLatest определяет идентификатор новейшей применимой версии пакета. // Бинарь/метаданные читаем из КОНКРЕТНОЙ версии, а не из pseudo-`latest`, // чтобы companion-файлы и бинарь всегда брались из одного снимка. +// +// Применимость версии проверяем НАЛИЧИЕМ бинаря платформы (HEAD без тела), +// а не скачиванием полного файла: при N версиях это N запросов заголовков +// вместо N×(размер бинаря) байт. func (u *Updater) ResolveLatest(ctx context.Context) (string, error) { - b, err := u.httpGet(u.versionsURL()) + b, err := u.httpGet(ctx, u.versionsURL()) if err != nil { return "", err } @@ -176,30 +252,28 @@ func (u *Updater) ResolveLatest(ctx context.Context) (string, error) { if err := json.Unmarshal(b, &vers); err != nil { return "", ue(U1, "list "+u.versionsURL(), err) } + // новые версии — с большим ID; идём с новейшей и берём первую с бинарём. + sort.SliceStable(vers, func(i, j int) bool { return vers[i].ID > vers[j].ID }) want := PlatformFilename() - // выбираем самую свежую версию (макс. id) класса commit-*, в которой есть бинарь. - best := "" - var bestID int64 for _, v := range vers { if !strings.HasPrefix(v.Ver, "commit-") { continue } - // версия применима, только если в ней опубликован бинарь нашей платформы - if _, err := u.httpGet(u.fileURL(v.Ver, want)); err != nil { - continue + exists, ferr := u.fileExists(ctx, u.fileURL(v.Ver, want)) + if ferr != nil { + continue // сетевые ошибки пробы не роняют проверку } - if v.ID > bestID { - bestID = v.ID - best = v.Ver + if exists { + return v.Ver, nil } } - return best, nil + return "", nil } // Download скачивает бинарь конкретной версии во временный файл и возвращает // путь к нему. Файл: /.ratatoskr..new. func (u *Updater) Download(ctx context.Context, version string) (string, error) { - b, err := u.httpGet(u.fileURL(version, PlatformFilename())) + b, err := u.httpGet(ctx, u.fileURL(version, PlatformFilename())) if err != nil { return "", err // уже U3 } @@ -216,7 +290,7 @@ func (u *Updater) Download(ctx context.Context, version string) (string, error) // versionSum256 читает companion-файл контрольной суммы конкретной версии. func (u *Updater) versionSum256(ctx context.Context, version string) (string, error) { - b, err := u.httpGet(u.fileURL(version, PlatformFilename()+".sha256")) + b, err := u.httpGet(ctx, u.fileURL(version, PlatformFilename()+".sha256")) if err != nil { return "", err } @@ -352,4 +426,4 @@ func (u *Updater) SwapAndRestart(file string) error { // успешный старт нового процесса — текущий завершаем os.Exit(0) return nil -} \ No newline at end of file +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go index c53d28b..398d211 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" ) @@ -41,7 +42,7 @@ func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Ser _ = json.NewEncoder(w).Encode(versions) }) mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method", http.StatusMethodNotAllowed) return } @@ -58,7 +59,10 @@ func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Ser http.NotFound(w, r) return } - _, _ = w.Write(body) + if r.Method == http.MethodGet { + _, _ = w.Write(body) + } + // HEAD — просто 200, тело не пишем }) return httptest.NewServer(mux) } @@ -133,6 +137,104 @@ func TestCheck_ServerDown(t *testing.T) { } } +// TestResolveLatest_HeadNotFullDownload проверяет, что ResolveLatest проверяет +// наличие бинаря HEAD'ом и НЕ скачивает полное тело бинаря (прошлая версия +// читала каждый файл целиком — O(N)×размер бинаря). +func TestResolveLatest_HeadNotFullDownload(t *testing.T) { + name := PlatformFilename() + _ = name + var headReqs, bodyReqs int64 + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": 1, "version": "commit-aaa1111"}, + {"id": 2, "version": "commit-abc1234"}, + }) + }) + mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 6 { + http.NotFound(w, r) + return + } + ver := parts[len(parts)-2] + fileName := parts[len(parts)-1] + if ver != "commit-abc1234" || fileName != name { + http.NotFound(w, r) + return + } + switch r.Method { + case http.MethodHead: + atomic.AddInt64(&headReqs, 1) + case http.MethodGet: + atomic.AddInt64(&bodyReqs, 1) + default: + http.Error(w, "method", http.StatusMethodNotAllowed) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v1", Dir: t.TempDir()} + ver, err := u.ResolveLatest(context.Background()) + if err != nil { + t.Fatalf("ResolveLatest err = %v", err) + } + if ver != "commit-abc1234" { + t.Errorf("ResolveLatest = %q, want commit-abc1234", ver) + } + if atomic.LoadInt64(&headReqs) == 0 { + t.Error("ResolveLatest не делал HEAD-проб на файлы") + } + if atomic.LoadInt64(&bodyReqs) != 0 { + t.Errorf("ResolveLatest скачал тело бинарника: %d полных GET", atomic.LoadInt64(&bodyReqs)) + } +} + +// TestResolveLatest_SkipsBinarylessVersion проверяет фоллбэк: новейшая версия +// без бинаря (разные матрицы платформ публикуются не все сразу) пропускается, +// берётся следующая, где файл есть. +func TestResolveLatest_SkipsBinarylessVersion(t *testing.T) { + name := PlatformFilename() + bin := []byte("binary") + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": 1, "version": "commit-aaa1111"}, + {"id": 2, "version": "commit-mid2222"}, + {"id": 3, "version": "commit-new3333"}, + }) + }) + mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 6 { + http.NotFound(w, r) + return + } + ver := parts[len(parts)-2] + fileName := parts[len(parts)-1] + // бинарь есть только у commit-mid2222 — новейшие 1 и 3 пропускаются + if ver != "commit-mid2222" || fileName != name { + http.NotFound(w, r) + return + } + if r.Method != http.MethodHead { + _, _ = w.Write(bin) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v0", Dir: t.TempDir()} + ver, err := u.ResolveLatest(context.Background()) + if err != nil { + t.Fatalf("ResolveLatest err = %v", err) + } + if ver != "commit-mid2222" { + t.Errorf("ResolveLatest = %q, want commit-mid2222 (фоллбэк от версии без бинаря)", ver) + } +} + func TestDownload_And_Verify_Good(t *testing.T) { bin := []byte("ratatoskr-binary-content-v2") u, dir := testUpdater(t, bin, "commit-new12345", "") @@ -249,4 +351,4 @@ func errorsAs(err error, target **Error) bool { err = c.Unwrap() } return false -} \ No newline at end of file +}