test: ≥95% coverage — daemon 96.9% (go), web 95.5-99% (vitest 142 tests); store.ts hooks-order crash fix
This commit is contained in:
@@ -4,4 +4,6 @@ dist
|
||||
*.db
|
||||
.pi/
|
||||
daemon/.pi-scratch/
|
||||
coverage
|
||||
daemon/lvmh-daemon
|
||||
e2e-integration-report.md
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
package main
|
||||
|
||||
// api_extra_test.go — spawn/status/container REST, gitlab handlers, webdist
|
||||
// serving, body/token validation edges.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newSpawnAPIServer builds a full Server (spawner → fake docker, gitlab →
|
||||
// fake upstream) for REST tests that need the spawn pipeline.
|
||||
func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) {
|
||||
t.Helper()
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
buildCtx := t.TempDir()
|
||||
dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile")
|
||||
if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
t.Setenv(envWorkerDockerfile, dockerfile)
|
||||
t.Setenv(envWorkerContext, buildCtx)
|
||||
t.Setenv(envRepoDir, t.TempDir())
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
gl := NewGitLab(store, "https://gitlab.example")
|
||||
srv := NewServer(store, hub, sp, gl)
|
||||
api := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
return api, f
|
||||
}
|
||||
|
||||
func apiReq(t *testing.T, method, url, token, body string) (int, string) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, rdr)
|
||||
if err != nil {
|
||||
t.Fatalf("new req: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func TestAPISpawnStatusAndContainerLifecycle(t *testing.T) {
|
||||
ts, _ := newSpawnAPIServer(t)
|
||||
auth := testToken
|
||||
|
||||
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project","branch":"main"}`)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("spawn = %d %s", code, body)
|
||||
}
|
||||
var res SpawnResult
|
||||
if err := json.Unmarshal([]byte(body), &res); err != nil {
|
||||
t.Fatalf("decode spawn result: %v", err)
|
||||
}
|
||||
if res.SessionID == "" || res.ContainerID != "" {
|
||||
t.Fatalf("spawn result = %+v", res)
|
||||
}
|
||||
|
||||
// async job reaches running; status endpoint lists it
|
||||
deadlineHit := false
|
||||
for i := 0; i < 200; i++ {
|
||||
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
var jobs []SpawnJob
|
||||
if err := json.Unmarshal([]byte(body), &jobs); err != nil {
|
||||
t.Fatalf("decode jobs: %v", err)
|
||||
}
|
||||
done := false
|
||||
for _, j := range jobs {
|
||||
if j.SessionID == res.SessionID && j.State == stateRunning {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
if done {
|
||||
deadlineHit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !deadlineHit {
|
||||
t.Fatal("job never reached running")
|
||||
}
|
||||
|
||||
// delete container: happy path then 404 after row deleted
|
||||
code, _ = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("delete container = %d", code)
|
||||
}
|
||||
code, body = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("second delete = %d %s, want 404", code, body)
|
||||
}
|
||||
|
||||
// docker stop failure → 500 covered in TestAPIDeleteContainerStopFailure
|
||||
}
|
||||
|
||||
func TestAPISpawnFailures(t *testing.T) {
|
||||
// docker unreachable from the start → POST /api/spawn surfaces 500
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
dead.Close()
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+dead.Listener.Addr().String())
|
||||
buildCtx := t.TempDir()
|
||||
t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile"))
|
||||
t.Setenv(envWorkerContext, buildCtx)
|
||||
t.Setenv(envRepoDir, t.TempDir())
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(store, hub, "https://gitlab.example")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
api := httptest.NewServer(NewServer(store, hub, sp, NewGitLab(store, "https://gitlab.example")).Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `not json`); code != http.StatusBadRequest {
|
||||
t.Fatalf("bad body = %d %s", code, body)
|
||||
}
|
||||
if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `{"repo":"group/project"}`); code != http.StatusInternalServerError {
|
||||
t.Fatalf("dead docker spawn = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDeleteContainerStopFailure(t *testing.T) {
|
||||
ts, f := newSpawnAPIServer(t)
|
||||
f.failStop = true
|
||||
auth := testToken
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project"}`); code != http.StatusCreated {
|
||||
t.Fatalf("spawn = %d %s", code, body)
|
||||
}
|
||||
// wait for a running job, then expect the DELETE to 500 on stop failure
|
||||
var sid string
|
||||
for i := 0; i < 200; i++ {
|
||||
_, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "")
|
||||
var jobs []SpawnJob
|
||||
_ = json.Unmarshal([]byte(body), &jobs)
|
||||
for _, j := range jobs {
|
||||
if j.State == stateRunning {
|
||||
sid = j.SessionID
|
||||
}
|
||||
}
|
||||
if sid != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sid == "" {
|
||||
t.Fatal("no running job")
|
||||
}
|
||||
if code, body := apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+sid+"/container", auth, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("delete with stop failure = %d %s, want 500", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// newGitLabAPIServer wires a Server whose GitLab points at a fake upstream.
|
||||
func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Private-Token") != "pat-good" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"username":"alice"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) {
|
||||
if broken {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `[{"path_with_namespace":"g/p","name":"P","namespace":{"path":"g"},
|
||||
"last_activity_at":"2024-01-01T00:00:00Z","web_url":"https://gl/g/p","default_branch":"main"}]`)
|
||||
})
|
||||
up := httptest.NewServer(mux)
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
srv := NewServer(store, hub, nil, NewGitLab(store, up.URL))
|
||||
api := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(api.Close)
|
||||
return api
|
||||
}
|
||||
|
||||
func TestAPIGitLabHandlers(t *testing.T) {
|
||||
ts := newGitLabAPIServer(t, false)
|
||||
auth := testToken
|
||||
|
||||
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", auth, "")
|
||||
if code != http.StatusOK || !strings.Contains(body, `"connected":false`) {
|
||||
t.Fatalf("status = %d %s", code, body)
|
||||
}
|
||||
// auth still required
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", "", ""); code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status = %d", code)
|
||||
}
|
||||
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `not json`); code != http.StatusBadRequest {
|
||||
t.Fatalf("connect bad body = %d %s", code, body)
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":" "}`); code != http.StatusBadRequest {
|
||||
t.Fatal("connect empty token must 400")
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-bad"}`); code != http.StatusInternalServerError {
|
||||
t.Fatal("connect invalid PAT must 500")
|
||||
}
|
||||
code, body = apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`)
|
||||
if code != http.StatusOK || !strings.Contains(body, `"username":"alice"`) {
|
||||
t.Fatalf("connect = %d %s", code, body)
|
||||
}
|
||||
|
||||
code, body = apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, "")
|
||||
if code != http.StatusOK || !strings.Contains(body, `"path":"g/p"`) {
|
||||
t.Fatalf("repos = %d %s", code, body)
|
||||
}
|
||||
|
||||
if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/gitlab/connect", auth, ""); code != http.StatusOK {
|
||||
t.Fatalf("disconnect = %d", code)
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, ""); code != http.StatusConflict {
|
||||
t.Fatalf("repos after disconnect = %d, want 409", code)
|
||||
}
|
||||
|
||||
// upstream 500 → 502 bad gateway
|
||||
broken := newGitLabAPIServer(t, true)
|
||||
apiReq(t, http.MethodPost, broken.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`)
|
||||
if code, _ := apiReq(t, http.MethodGet, broken.URL+"/api/gitlab/repos", auth, ""); code != http.StatusBadGateway {
|
||||
t.Fatalf("repos with broken upstream = %d, want 502", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPromptBodyValidation(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
auth := testToken
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `not json`); code != http.StatusBadRequest {
|
||||
t.Fatal("bad json must 400")
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `{"message":" "}`); code != http.StatusBadRequest {
|
||||
t.Fatal("blank message must 400")
|
||||
}
|
||||
// bodies over 1 MiB are rejected by MaxBytesReader
|
||||
big := `{"message":"` + strings.Repeat("x", 1<<20) + `"}`
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, big); code != http.StatusBadRequest {
|
||||
t.Fatal("oversized body must 400")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEventsEdgeCases(t *testing.T) {
|
||||
ts, store := newTestServer(t)
|
||||
auth := testToken
|
||||
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?after=-1", auth, ""); code != http.StatusBadRequest {
|
||||
t.Fatal("after=-1 must 400")
|
||||
}
|
||||
// limit clamped above the max: still 200 with all events
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?limit=99999999", auth, ""); code != http.StatusOK {
|
||||
t.Fatal("huge limit must be clamped, not 400")
|
||||
}
|
||||
// non-object payload degrades to an envelope-only frame
|
||||
if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 5, Type: evMessageEnd, Payload: []byte(`[1,2]`)}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
_, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", auth, "")
|
||||
var frames []map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &frames); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0]["message"] != nil || frames[0]["type"] != evMessageEnd {
|
||||
t.Fatalf("frame with non-object payload = %v", frames[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenEmptyRejected(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
daemonToken = ""
|
||||
t.Cleanup(func() { daemonToken = testToken })
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions", "anything", ""); code != http.StatusUnauthorized {
|
||||
t.Fatal("empty configured token must reject everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAbortOffline409(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/abort", testToken, ""); code != http.StatusConflict {
|
||||
t.Fatal("abort offline must 409")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIHandlersWithClosedStore(t *testing.T) {
|
||||
// events endpoint surfaces store errors as 500; gitlab disconnect too
|
||||
ts, store := newTestServer(t)
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatal("events with broken store must 500")
|
||||
}
|
||||
gl := NewGitLab(store, "https://gitlab.example")
|
||||
daemonToken = testToken
|
||||
hub2 := NewHub(store)
|
||||
ts2 := httptest.NewServer(NewServer(store, hub2, nil, gl).Routes(""))
|
||||
t.Cleanup(ts2.Close)
|
||||
if code, _ := apiReq(t, http.MethodDelete, ts2.URL+"/api/gitlab/connect", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatal("gitlab disconnect with broken store must 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIWebHandlerDirOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("INDEX"), 0o644); err != nil {
|
||||
t.Fatalf("write index: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("APP"), 0o644); err != nil {
|
||||
t.Fatalf("write app: %v", err)
|
||||
}
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
srv := NewServer(store, hub, nil, NewGitLab(store, "https://gitlab.example"))
|
||||
ts := httptest.NewServer(srv.Routes(dir))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
code, body := apiReq(t, http.MethodGet, ts.URL+"/app.js", "", "")
|
||||
if code != http.StatusOK || body != "APP" {
|
||||
t.Fatalf("app.js = %d %q", code, body)
|
||||
}
|
||||
code, body = apiReq(t, http.MethodGet, ts.URL+"/missing/route", "", "")
|
||||
if code != http.StatusOK || body != "INDEX" {
|
||||
t.Fatalf("SPA fallback = %d %q", code, body)
|
||||
}
|
||||
code, body = apiReq(t, http.MethodGet, ts.URL+"/", "", "")
|
||||
if code != http.StatusOK || body != "INDEX" {
|
||||
t.Fatalf("root = %d %q", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIWebHandlerEmbedded(t *testing.T) {
|
||||
ts, _ := newTestServer(t) // Routes("") → embedded webdist
|
||||
|
||||
for path, wantStatus := range map[string]int{"/index.html": 200, "/no-such-page": 200, "/": 200} {
|
||||
code, body := apiReq(t, http.MethodGet, ts.URL+path, "", "")
|
||||
if code != wantStatus {
|
||||
t.Fatalf("%s = %d", path, code)
|
||||
}
|
||||
if !strings.Contains(body, "<") {
|
||||
t.Fatalf("%s body = %q", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package main
|
||||
|
||||
// docker_test.go — spawner pipeline against a fake docker REST API and a
|
||||
// fake git binary (PATH shim recording invocations).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- fake docker daemon (just enough REST for the spawner pipeline) ---
|
||||
|
||||
type dockerCall struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (c dockerCall) String() string { return c.Method + " " + c.Path + "?" + c.Query }
|
||||
|
||||
// recordedCreate is the parsed POST /containers/create request body.
|
||||
type recordedCreate struct {
|
||||
Name string
|
||||
Image string `json:"Image"`
|
||||
Env []string `json:"Env"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Cmd []string `json:"Cmd"`
|
||||
HostConfig struct {
|
||||
Binds []string `json:"Binds"`
|
||||
NetworkMode string `json:"NetworkMode"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
type fakeDocker struct {
|
||||
mu sync.Mutex
|
||||
|
||||
calls []dockerCall
|
||||
images int // entries served by GET /images/json
|
||||
volume map[string]bool // existing volumes
|
||||
nextID int
|
||||
create []recordedCreate
|
||||
|
||||
failBuild bool
|
||||
failBuildHTTP bool
|
||||
failCreate bool
|
||||
failStart bool
|
||||
failStop bool
|
||||
failWait bool
|
||||
failVolumeCreate bool
|
||||
waitHang bool
|
||||
|
||||
unknown []string
|
||||
}
|
||||
|
||||
func newFakeDocker() *fakeDocker {
|
||||
return &fakeDocker{images: 1, volume: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (f *fakeDocker) server(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(f)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// record appends a call; the /vX.Y version prefix negotiated by the SDK is
|
||||
// stripped so assertions are version-agnostic.
|
||||
func (f *fakeDocker) record(r *http.Request, body string) dockerCall {
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/v") {
|
||||
if i := strings.Index(path[1:], "/"); i >= 0 {
|
||||
path = path[i+1:]
|
||||
}
|
||||
}
|
||||
c := dockerCall{Method: r.Method, Path: path, Query: r.URL.RawQuery, Body: body}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, c)
|
||||
f.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
func (f *fakeDocker) hasCall(method, pathPrefix string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeDocker) hasCallSuffix(method, suffix string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
if c.Method == method && strings.HasSuffix(c.Path, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeDocker) countCalls(method, pathPrefix string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range f.calls {
|
||||
if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (f *fakeDocker) createsByName(prefix string) []recordedCreate {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []recordedCreate
|
||||
for _, c := range f.create {
|
||||
if strings.HasPrefix(c.Name, prefix) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeDocker) volumeExists(name string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.volume[name]
|
||||
}
|
||||
|
||||
func (f *fakeDocker) assertNoUnknown(t *testing.T) {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.unknown) > 0 {
|
||||
t.Fatalf("fake docker got unhandled requests: %v", f.unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONNow(w http.ResponseWriter, status int, v string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, v)
|
||||
}
|
||||
|
||||
func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
call := f.record(r, string(body))
|
||||
switch {
|
||||
case call.Path == "/_ping":
|
||||
w.Header().Set("Api-Version", "1.44")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case call.Method == http.MethodGet && call.Path == "/images/json":
|
||||
list := "[]"
|
||||
if f.images > 0 {
|
||||
list = `[{"Id":"sha256:abc","RepoTags":["lvmh-worker:latest"]}]`
|
||||
}
|
||||
writeJSONNow(w, http.StatusOK, list)
|
||||
case call.Method == http.MethodPost && call.Path == "/build":
|
||||
if f.failBuildHTTP {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`)
|
||||
return
|
||||
}
|
||||
if f.failBuild {
|
||||
writeJSONNow(w, http.StatusOK, `{"errorDetail":{"message":"build exploded"},"error":"build exploded"}`+"\n")
|
||||
return
|
||||
}
|
||||
writeJSONNow(w, http.StatusOK, `{"stream":"Successfully tagged lvmh-worker:latest"}`+"\n")
|
||||
case call.Method == http.MethodPost && call.Path == "/volumes/create":
|
||||
var req struct {
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
f.mu.Lock()
|
||||
f.volume[req.Name] = true
|
||||
f.mu.Unlock()
|
||||
if f.failVolumeCreate {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume create failed"}`)
|
||||
return
|
||||
}
|
||||
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, req.Name))
|
||||
case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/volumes/"):
|
||||
name := strings.TrimPrefix(call.Path, "/volumes/")
|
||||
f.mu.Lock()
|
||||
exists := f.volume[name]
|
||||
f.mu.Unlock()
|
||||
if exists {
|
||||
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
|
||||
return
|
||||
}
|
||||
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
|
||||
case call.Method == http.MethodPost && call.Path == "/containers/create":
|
||||
var rc recordedCreate
|
||||
if err := json.Unmarshal(body, &rc); err != nil {
|
||||
writeJSONNow(w, http.StatusBadRequest, `{"message":"bad create body"}`)
|
||||
return
|
||||
}
|
||||
rc.Name = r.URL.Query().Get("name")
|
||||
if f.failCreate {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"create failed"}`)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("cid-%d", f.nextID)
|
||||
f.create = append(f.create, rc)
|
||||
f.mu.Unlock()
|
||||
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id))
|
||||
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"):
|
||||
if f.failStart {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/stop"):
|
||||
if f.failStop {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"stop failed"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"):
|
||||
if f.failWait {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"wait failed"}`)
|
||||
return
|
||||
}
|
||||
if f.waitHang {
|
||||
<-r.Context().Done() // hold the connection until the client gives up
|
||||
return
|
||||
}
|
||||
writeJSONNow(w, http.StatusOK, `{"StatusCode":0,"Error":null}`)
|
||||
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"):
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
f.mu.Lock()
|
||||
f.unknown = append(f.unknown, call.String())
|
||||
f.mu.Unlock()
|
||||
writeJSONNow(w, http.StatusNotFound, `{"message":"not implemented: `+call.String()+`"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- fake git (PATH shim): logs invocations, optionally fails ---
|
||||
|
||||
const (
|
||||
fakeGitModeOK string = "ok"
|
||||
fakeGitModeFail string = "fail"
|
||||
fakeGitModeNoisy string = "noisy"
|
||||
fakeGitModeSilent string = "silent"
|
||||
)
|
||||
|
||||
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
|
||||
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests).
|
||||
func useFakeGit(t *testing.T, mode string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(t.TempDir(), "git-calls.log")
|
||||
script := "#!/bin/sh\n" +
|
||||
"printf '%s\\n' \"$*\" >> \"" + logPath + "\"\n" +
|
||||
"case \"$FAKE_GIT_MODE\" in\n" +
|
||||
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
|
||||
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
|
||||
" silent) exit 1;;\n" +
|
||||
"esac\n" +
|
||||
"exit 0\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake git: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("FAKE_GIT_MODE", mode)
|
||||
return logPath
|
||||
}
|
||||
|
||||
func readGitLog(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("read git log: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- spawner construction wired at the fake daemon ---
|
||||
|
||||
func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
|
||||
t.Helper()
|
||||
ts := f.server(t)
|
||||
t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String())
|
||||
|
||||
buildCtx := t.TempDir()
|
||||
dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile")
|
||||
if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir dockerfile dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatalf("write dockerfile: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(buildCtx, "app.txt"), []byte("app"), 0o644); err != nil {
|
||||
t.Fatalf("write app.txt: %v", err)
|
||||
}
|
||||
t.Setenv(envWorkerDockerfile, dockerfile)
|
||||
t.Setenv(envWorkerContext, buildCtx)
|
||||
t.Setenv(envRepoDir, t.TempDir())
|
||||
t.Setenv(envProviderAPIKey, "key-123")
|
||||
t.Setenv(envWorkerModels, "") // fall back to default path (absent) → no models bind
|
||||
|
||||
daemonToken = testToken
|
||||
store := openTestStore(t)
|
||||
hub := NewHub(store)
|
||||
sp, err := NewSpawner(store, hub, "https://gitlab.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
return sp, store
|
||||
}
|
||||
|
||||
// waitJobState polls JobsSnapshot until the job for sessionID reaches want.
|
||||
func waitJobState(t *testing.T, sp *Spawner, sessionID, want string) SpawnJob {
|
||||
t.Helper()
|
||||
var job SpawnJob
|
||||
waitFor(t, 5*time.Second, func() bool {
|
||||
for _, j := range sp.JobsSnapshot() {
|
||||
if j.SessionID == sessionID {
|
||||
job = j
|
||||
return j.State == want
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return job
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
// gitlab_extra_test.go — error taxonomy, insecure client seam, decode failures.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGitLabErrorType(t *testing.T) {
|
||||
err := &GitLabError{msg: "upstream sad"}
|
||||
if err.Error() != "upstream sad" {
|
||||
t.Fatalf("GitLabError.Error = %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitLabWithClientInsecure(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
gl := NewGitLabWithClient(store, "https://gl.example/", true)
|
||||
if !gl.insecure {
|
||||
t.Fatal("insecure flag not set")
|
||||
}
|
||||
hc := gl.httpClient()
|
||||
if hc.Transport == nil {
|
||||
t.Fatal("insecure client must install a custom transport")
|
||||
}
|
||||
plain := NewGitLab(store, "https://gl.example/")
|
||||
if plain.httpClient().Transport != nil {
|
||||
t.Fatal("default client must not override the transport")
|
||||
}
|
||||
if !strings.HasSuffix(gl.baseURL, "example") || strings.HasSuffix(gl.baseURL, "/") {
|
||||
t.Fatalf("baseURL trailing slash not trimmed: %q", gl.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabConnectDecodeAndEmptyUser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"decode-failure", "not json", "decode"},
|
||||
{"empty-username", `{}`, "no username"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
gl := NewGitLab(openTestStore(t), up.URL)
|
||||
_, err := gl.Connect(context.Background(), "pat")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Connect = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabRequestFailures(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
|
||||
// unparseable base URL → request construction error
|
||||
gl := NewGitLab(store, "https://gl.example/\n")
|
||||
if _, err := gl.Connect(context.Background(), "pat"); err == nil {
|
||||
t.Fatal("connect against unparseable URL must fail")
|
||||
}
|
||||
|
||||
// transport failure (cancelled context) wraps into GitLabError
|
||||
gl2 := NewGitLab(store, "https://gl.example")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := gl2.Connect(ctx, "pat")
|
||||
if err == nil || !strings.Contains(err.Error(), "gitlab request failed") {
|
||||
t.Fatalf("connect with dead context = %v, want GitLabError", err)
|
||||
}
|
||||
|
||||
// store failures surface from token() and Disconnect
|
||||
dead := openTestStore(t)
|
||||
_ = dead.Close()
|
||||
gl3 := NewGitLab(dead, "https://gl.example")
|
||||
if _, err := gl3.Repos(context.Background()); err == nil || !strings.Contains(err.Error(), "closed") {
|
||||
t.Fatalf("repos with closed store = %v", err)
|
||||
}
|
||||
if err := gl3.Disconnect(); err == nil {
|
||||
t.Fatal("disconnect with closed store must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabStatusStoreFailure(t *testing.T) {
|
||||
dead := openTestStore(t)
|
||||
_ = dead.Close()
|
||||
gl := NewGitLab(dead, "https://gl.example")
|
||||
status := gl.Status()
|
||||
if status["connected"] != false {
|
||||
t.Fatalf("status with broken store = %v, want connected=false", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabConnectStoreFailures(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"username":"alice"}`))
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
dead := openTestStore(t)
|
||||
_ = dead.Close()
|
||||
gl := NewGitLab(dead, up.URL)
|
||||
if _, err := gl.Connect(context.Background(), "pat"); err == nil {
|
||||
t.Fatal("Connect with closed store must fail at SetSetting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"path_with_namespace":"","name":"X"}]`))
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
store := openTestStore(t)
|
||||
gl := NewGitLab(store, up.URL)
|
||||
if err := store.SetSetting(settingGitLabToken, "pat"); err != nil {
|
||||
t.Fatalf("set token: %v", err)
|
||||
}
|
||||
repos, err := gl.Repos(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("repos: %v", err)
|
||||
}
|
||||
if len(repos) != 0 {
|
||||
t.Fatalf("repos = %+v, want empty (blank path skipped)", repos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
// hub_extra_test.go — session_info, spawn_status push, send/overflow
|
||||
// branches, upgrade failures, closed-store resilience.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestHubSessionInfoUpdatesSession(t *testing.T) {
|
||||
ts, store := newTestServer(t)
|
||||
ws := dialAgent(t, ts)
|
||||
_ = ws.WriteJSON(helloFrame("s1"))
|
||||
_ = readFrame(t, ws)
|
||||
|
||||
// malformed session_info (no session payload) is ignored, conn stays up
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "sessionId": "s1", "seq": 1, "ts": 2})
|
||||
renamed := "renamed"
|
||||
// envelope sessionId missing → falls back to session.id
|
||||
info := map[string]any{"id": "s1", "name": renamed, "cwd": "/w", "model": "glm-4", "provider": "p", "startedAt": 99}
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "seq": 2, "ts": 3, "session": info})
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
rows, err := store.Sessions()
|
||||
if err != nil || len(rows) != 1 {
|
||||
return false
|
||||
}
|
||||
return rows[0].Info.Model == "glm-4"
|
||||
})
|
||||
rows, _ := store.Sessions()
|
||||
if rows[0].Info.ID != "s1" || rows[0].Info.Name == nil || *rows[0].Info.Name != renamed {
|
||||
t.Fatalf("session after session_info = %+v", rows[0].Info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHelloFallbacksAndBadPayload(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
ws := dialAgent(t, ts)
|
||||
|
||||
// hello without session payload → logged, ignored, conn lives on
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evHello, "sessionId": "s1", "seq": 0, "ts": 1})
|
||||
// hello with session.id but no envelope sessionId → registers under session.id
|
||||
frame := helloFrame("ignored")
|
||||
frame["sessionId"] = nil
|
||||
_ = ws.WriteJSON(frame)
|
||||
welcome := readFrame(t, ws)
|
||||
if welcome["type"] != evWelcome || welcome["sessionId"] != "ignored" {
|
||||
t.Fatalf("welcome = %v, want fallback to session.id", welcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubIsOnline(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
if hub.IsOnline("nope") {
|
||||
t.Fatal("unknown session cannot be online")
|
||||
}
|
||||
ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
|
||||
hub.agents["s1"] = ac
|
||||
if !hub.IsOnline("s1") {
|
||||
t.Fatal("registered conn must report online")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubBroadcastSpawnStatus(t *testing.T) {
|
||||
ts, _, hub := newTestServerHub(t)
|
||||
hub.SpawnStatus = func() []SpawnJob {
|
||||
return []SpawnJob{{Repo: "group/proj", State: stateRunning, ContainerID: "cid-1"}}
|
||||
}
|
||||
web := dialWeb(t, ts)
|
||||
if first := readFrame(t, web); first["type"] != frameSessionList {
|
||||
t.Fatalf("first frame = %v", first)
|
||||
}
|
||||
hub.BroadcastSpawnStatus()
|
||||
_ = web.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var got map[string]any
|
||||
for got == nil {
|
||||
if m := readFrame(t, web); m["type"] == frameSpawnStatus {
|
||||
got = m
|
||||
}
|
||||
}
|
||||
jobs := got["jobs"].([]any)
|
||||
if len(jobs) != 1 || jobs[0].(map[string]any)["repo"] != "group/proj" {
|
||||
t.Fatalf("spawn_status frame = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServerHub is newTestServer but also hands back the hub so tests can
|
||||
// wire SpawnStatus themselves (NewServer normally does).
|
||||
func newTestServerHub(t *testing.T) (*httptest.Server, *Store, *Hub) {
|
||||
t.Helper()
|
||||
store := openTestStore(t)
|
||||
daemonToken = testToken
|
||||
hub := NewHub(store)
|
||||
srv := &Server{store: store, hub: hub, gitlab: NewGitLab(store, "https://gitlab.example")}
|
||||
ts := httptest.NewServer(srv.Routes(""))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, store, hub
|
||||
}
|
||||
|
||||
// throwawayConn is a client-side websocket whose only job is being closeable
|
||||
// (agentConn.drop touches conn).
|
||||
func throwawayConn(t *testing.T) *websocket.Conn {
|
||||
t.Helper()
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Close()
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial throwaway: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func dialWeb(t *testing.T, ts *httptest.Server) *websocket.Conn {
|
||||
t.Helper()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + testToken
|
||||
web, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial web ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = web.Close() })
|
||||
return web
|
||||
}
|
||||
|
||||
func TestHubUpgradeFailures(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
|
||||
get := func(path string) int {
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
// plain HTTP GETs (no upgrade headers) must not 500
|
||||
if code := get("/agent/ws"); code != http.StatusBadRequest {
|
||||
t.Fatalf("agent ws non-upgrade = %d, want 400", code)
|
||||
}
|
||||
if code := get("/ws?token=" + testToken); code != http.StatusBadRequest {
|
||||
t.Fatalf("web ws non-upgrade = %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToAgentBranches(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
|
||||
// dropped conn: done closed → ErrOffline via done branch (send kept full
|
||||
// so the send case cannot win the select race)
|
||||
dead := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
|
||||
dead.send <- []byte("fill")
|
||||
dead.drop()
|
||||
hub.agents["gone"] = dead
|
||||
if err := hub.sendToAgent("gone", map[string]any{"type": evAbort}); err != ErrOffline {
|
||||
t.Fatalf("sendToAgent dropped conn = %v, want ErrOffline", err)
|
||||
}
|
||||
|
||||
// live conn but nobody drains send: timeout branch (3s)
|
||||
stuck := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
|
||||
stuck.send <- []byte("fill")
|
||||
hub.agents["stuck"] = stuck
|
||||
start := time.Now()
|
||||
if err := hub.sendToAgent("stuck", map[string]any{"type": evAbort}); err != ErrOffline {
|
||||
t.Fatalf("sendToAgent stuck conn = %v, want ErrOffline", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < promptSendTimeout-100*time.Millisecond {
|
||||
t.Fatalf("sendToAgent returned after %v, want full timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebClientOverflowDrops(t *testing.T) {
|
||||
// real ws conn so drop() has something to close
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Close()
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
wsURL := "ws" + strings.TrimPrefix(up.URL, "http")
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
hub := NewHub(openTestStore(t))
|
||||
c := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})}
|
||||
for i := 0; i < webMaxPending; i++ {
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")})
|
||||
}
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")}) // overflow → dropped
|
||||
select {
|
||||
case <-c.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("overflowed web client was not dropped")
|
||||
}
|
||||
if !c.dropped {
|
||||
t.Fatal("client must be marked dropped")
|
||||
}
|
||||
// deliveries to a dropped client are no-ops
|
||||
c.deliverControl([]byte("{}"))
|
||||
if len(c.control) != 0 {
|
||||
t.Fatal("dropped client must not queue control frames")
|
||||
}
|
||||
|
||||
// control-frame overflow path on a fresh client
|
||||
c2 := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})}
|
||||
for i := 0; i < webMaxPending; i++ {
|
||||
c2.deliverControl([]byte("{}"))
|
||||
}
|
||||
c2.deliverControl([]byte("{}"))
|
||||
select {
|
||||
case <-c2.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("control-overflowed web client was not dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWritePumpDropsOnWriteError(t *testing.T) {
|
||||
serverConn := make(chan *websocket.Conn, 1)
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
serverConn <- c
|
||||
for { // keep server side alive until client vanishes
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = clientConn.Close() })
|
||||
var server *websocket.Conn
|
||||
select {
|
||||
case server = <-serverConn:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server conn never upgraded")
|
||||
}
|
||||
|
||||
hub := NewHub(openTestStore(t))
|
||||
ac := &agentConn{hub: hub, conn: server, send: make(chan []byte, 4), done: make(chan struct{})}
|
||||
go ac.writePump()
|
||||
// healthy write first
|
||||
ac.send <- []byte(`{"type":"welcome"}`)
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, _, err := clientConn.ReadMessage(); err != nil {
|
||||
t.Fatalf("first write lost: %v", err)
|
||||
}
|
||||
// kill the TCP conn underneath gorilla, then keep writing
|
||||
if err := clientConn.UnderlyingConn().Close(); err != nil {
|
||||
t.Fatalf("close tcp: %v", err)
|
||||
}
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case ac.send <- []byte(`{"type":"abort"}`):
|
||||
case <-ac.done:
|
||||
return // writePump hit the write error and dropped
|
||||
case <-deadline:
|
||||
t.Fatal("writePump did not drop after write failure")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubClosedStoreKeepsServing(t *testing.T) {
|
||||
ts, store := newTestServer(t)
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
|
||||
ws := dialAgent(t, ts)
|
||||
_ = ws.WriteJSON(helloFrame("s1"))
|
||||
// welcome still arrives with lastSeq 0 despite store failures
|
||||
if welcome := readFrame(t, ws); welcome["type"] != evWelcome || welcome["lastSeq"].(float64) != 0 {
|
||||
t.Fatalf("welcome with broken store = %v", welcome)
|
||||
}
|
||||
// events are not persisted but fan-out still happens; conn must survive
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evAgentSettled, "sessionId": "s1", "seq": 1, "ts": 5})
|
||||
|
||||
hub := NewHub(store) // store already closed
|
||||
if views := hub.SessionsView(); len(views) != 0 {
|
||||
t.Fatalf("SessionsView with broken store = %v, want empty", views)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubBroadcastSpawnStatusNilProvider(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
hub.BroadcastSpawnStatus() // SpawnStatus nil → no-op, must not panic
|
||||
hub.SpawnStatus = func() []SpawnJob { return nil }
|
||||
hub.BroadcastSpawnStatus() // provider returning nil jobs → empty frame
|
||||
}
|
||||
|
||||
func TestHubByeClosesConnAndEventWithoutSessionIgnored(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
ws := dialAgent(t, ts)
|
||||
_ = ws.WriteJSON(helloFrame("s1"))
|
||||
_ = readFrame(t, ws)
|
||||
|
||||
// event frames without sessionId are dropped silently
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evMessageEnd, "seq": 9, "ts": 9})
|
||||
// bye closes the conn from the server side
|
||||
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evBye, "sessionId": "s1", "seq": 10, "ts": 10})
|
||||
_ = ws.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, _, err := ws.ReadMessage(); err == nil {
|
||||
t.Fatal("conn must close after bye")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHelloWelcomeSendDropWhenQueueFull(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
|
||||
ac.send <- []byte("full") // nobody drains → welcome select takes default → drop
|
||||
hello := helloFrame("s1")
|
||||
raw, _ := json.Marshal(hello)
|
||||
hub.handleHello(ac, frame{typ: evHello, sessionID: "s1", raw: raw})
|
||||
select {
|
||||
case <-ac.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("agent with full send queue must be dropped at hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubWebReadWritePumpEdges(t *testing.T) {
|
||||
ts, _, hub := newTestServerHub(t)
|
||||
web := dialWeb(t, ts)
|
||||
if first := readFrame(t, web); first["type"] != frameSessionList {
|
||||
t.Fatalf("first frame = %v", first)
|
||||
}
|
||||
// garbage frames must not kill the read pump
|
||||
if err := web.WriteMessage(websocket.TextMessage, []byte("not json")); err != nil {
|
||||
t.Fatalf("write garbage: %v", err)
|
||||
}
|
||||
if err := web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"}); err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
for c := range hub.webs {
|
||||
return c.subscription() == "s1"
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err := web.WriteJSON(map[string]any{"type": frameUnsubscribe}); err != nil {
|
||||
t.Fatalf("unsubscribe: %v", err)
|
||||
}
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
for c := range hub.webs {
|
||||
return c.subscription() == ""
|
||||
}
|
||||
return false
|
||||
})
|
||||
// client disconnects → readPump exits, client unregistered
|
||||
_ = web.Close()
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
return len(hub.webs) == 0
|
||||
})
|
||||
}
|
||||
|
||||
func TestHubDropWebIdempotent(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
|
||||
hub.mu.Lock()
|
||||
hub.webs[c] = struct{}{}
|
||||
hub.mu.Unlock()
|
||||
hub.dropWeb(c)
|
||||
hub.dropWeb(c) // second drop hits the already-removed early return
|
||||
select {
|
||||
case <-c.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("web client was not dropped")
|
||||
}
|
||||
hub.mu.Lock()
|
||||
left := len(hub.webs)
|
||||
hub.mu.Unlock()
|
||||
if left != 0 {
|
||||
t.Fatalf("webs after drop = %d, want 0", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebWritePumpDropsOnWriteError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctrl bool // deliver control frames (vs events only)
|
||||
}{
|
||||
{"control-write", true},
|
||||
{"events-write", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
serverConn := make(chan *websocket.Conn, 1)
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
serverConn <- c
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(up.Close)
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = clientConn.Close() })
|
||||
var server *websocket.Conn
|
||||
select {
|
||||
case server = <-serverConn:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server conn never upgraded")
|
||||
}
|
||||
|
||||
hub := NewHub(openTestStore(t))
|
||||
c := &webClient{hub: hub, conn: server, done: make(chan struct{})}
|
||||
hub.mu.Lock()
|
||||
hub.webs[c] = struct{}{} // writePump errors route through dropWeb
|
||||
hub.mu.Unlock()
|
||||
go c.writePump()
|
||||
if tc.ctrl {
|
||||
c.deliverControl([]byte(`{"type":"session_list"}`))
|
||||
} else {
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")})
|
||||
}
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, _, err := clientConn.ReadMessage(); err != nil {
|
||||
t.Fatalf("first frame lost: %v", err)
|
||||
}
|
||||
if err := clientConn.UnderlyingConn().Close(); err != nil {
|
||||
t.Fatalf("close tcp: %v", err)
|
||||
}
|
||||
// flush cycle hits the dead conn → writePump drops the client
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
if tc.ctrl {
|
||||
c.deliverControl([]byte(`{"type":"session_list"}`))
|
||||
} else {
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: 2, raw: []byte("{}")})
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("writePump did not drop after write failure")
|
||||
default:
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebClientDeliverToDropped(t *testing.T) {
|
||||
hub := NewHub(openTestStore(t))
|
||||
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
|
||||
for i := 0; i < webMaxPending+1; i++ { // overflow sets the dropped flag
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")})
|
||||
}
|
||||
c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")})
|
||||
if len(c.events) != webMaxPending {
|
||||
t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending)
|
||||
}
|
||||
}
|
||||
+31
-10
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -26,12 +27,23 @@ const (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the testable main: parses args, wires the server, blocks on SIGINT/
|
||||
// SIGTERM, then shuts down. Split out of main for coverage of the wiring.
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("lvmh-daemon", flag.ContinueOnError)
|
||||
var (
|
||||
addr = flag.String("addr", defaultListenAddr, "listen address")
|
||||
dbPath = flag.String("db", envOr(envDB, defaultDBPath), "sqlite database path")
|
||||
webdist = flag.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build")
|
||||
addr = fs.String("addr", defaultListenAddr, "listen address")
|
||||
dbPath = fs.String("db", envOr(envDB, defaultDBPath), "sqlite database path")
|
||||
webdist = fs.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build")
|
||||
)
|
||||
flag.Parse()
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
// Fall back to the embedded UI when the override directory is absent.
|
||||
if st, err := os.Stat(*webdist); err != nil || !st.IsDir() {
|
||||
*webdist = ""
|
||||
@@ -39,17 +51,17 @@ func main() {
|
||||
|
||||
daemonToken = os.Getenv(envToken)
|
||||
if daemonToken == "" {
|
||||
log.Fatalf("LVMH_TOKEN must be set")
|
||||
return fmt.Errorf("LVMH_TOKEN must be set")
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(*dbPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Fatalf("create db dir: %v", err)
|
||||
return fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
}
|
||||
store, err := OpenStore(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -57,7 +69,7 @@ func main() {
|
||||
gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase))
|
||||
spawner, err := NewSpawner(store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
|
||||
if err != nil {
|
||||
log.Fatalf("docker client: %v", err)
|
||||
return fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
|
||||
srv := NewServer(store, hub, spawner, gitlab)
|
||||
@@ -67,20 +79,29 @@ func main() {
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("lvmh daemon listening on %s (db %s)", *addr, *dbPath)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
serveErr <- err
|
||||
return
|
||||
}
|
||||
serveErr <- nil
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
defer signal.Stop(stop)
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
case <-stop:
|
||||
}
|
||||
log.Printf("shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
// main_test.go — run() wiring: flag errors, missing token, db dir creation,
|
||||
// listen, graceful SIGTERM shutdown.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freeTestAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ls, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listener: %v", err)
|
||||
}
|
||||
defer ls.Close()
|
||||
return fmt.Sprintf("127.0.0.1:%d", ls.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
|
||||
func TestRunFlagError(t *testing.T) {
|
||||
if err := run([]string{"--nope"}); err == nil {
|
||||
t.Fatal("unknown flag must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresToken(t *testing.T) {
|
||||
t.Setenv(envToken, "")
|
||||
if err := run(nil); err == nil || err.Error() != "LVMH_TOKEN must be set" {
|
||||
t.Fatalf("run without token = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBadDBPath(t *testing.T) {
|
||||
t.Setenv(envToken, "s3cret")
|
||||
occupied := filepath.Join(t.TempDir(), "file.db")
|
||||
if err := os.WriteFile(occupied, nil, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := run([]string{"--db", filepath.Join(occupied, "sub", "lvmh.db")}); err == nil {
|
||||
t.Fatal("run with db under a file must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunListenAndServeShutdown(t *testing.T) {
|
||||
addr := freeTestAddr(t)
|
||||
dir := t.TempDir()
|
||||
db := filepath.Join(dir, "nested", "lvmh.db")
|
||||
t.Setenv(envToken, "s3cret")
|
||||
t.Setenv(envDB, db)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- run([]string{"--addr", addr, "--webdist", filepath.Join(dir, "absent")}) }()
|
||||
|
||||
// server comes up: /api answers 401 without a token
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
responded := false
|
||||
for time.Now().Before(deadline) {
|
||||
if resp, err := http.Get("http://" + addr + "/api/sessions"); err == nil {
|
||||
resp.Body.Close()
|
||||
responded = resp.StatusCode == 401
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !responded {
|
||||
t.Fatal("daemon never answered /api/sessions with 401")
|
||||
}
|
||||
if _, err := os.Stat(db); err != nil {
|
||||
t.Fatalf("db not created at %s: %v", db, err)
|
||||
}
|
||||
|
||||
// SIGTERM → graceful shutdown, run returns nil
|
||||
if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("run returned %v, want nil", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run did not return after SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunListenFailure(t *testing.T) {
|
||||
t.Setenv(envToken, "s3cret")
|
||||
ls, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("occupy listener: %v", err)
|
||||
}
|
||||
defer ls.Close()
|
||||
port := ls.Addr().(*net.TCPAddr).Port
|
||||
db := filepath.Join(t.TempDir(), "lvmh.db")
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run([]string{"--addr", fmt.Sprintf("127.0.0.1:%d", port), "--db", db}) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "listen") {
|
||||
t.Fatalf("run on occupied port = %v, want listen error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run never returned on listen failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOpenStoreFailure(t *testing.T) {
|
||||
t.Setenv(envToken, "s3cret")
|
||||
// --db pointing at an existing directory: schema exec fails
|
||||
if err := run([]string{"--db", t.TempDir(), "--addr", "127.0.0.1:0"}); err == nil || !strings.Contains(err.Error(), "open store") {
|
||||
t.Fatalf("run with directory db = %v, want open store error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
package main
|
||||
|
||||
// spawner_test.go — clone→build→create/start pipeline, git failures, env wiring.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func TestSpawnerStartHappyPath(t *testing.T) {
|
||||
gitLog := useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if res.SessionID == "" || res.ContainerID != "" {
|
||||
t.Fatalf("Start result = %+v, want sessionId set and empty containerId", res)
|
||||
}
|
||||
|
||||
job := waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
if job.Repo != "group/project" || job.ContainerID == "" {
|
||||
t.Fatalf("running job = %+v, want containerId set", job)
|
||||
}
|
||||
|
||||
// clone URL carries the PAT only when none is stored (it isn't here)
|
||||
calls := readGitLog(t, gitLog)
|
||||
if len(calls) != 1 || !strings.HasPrefix(calls[0], "clone --branch main -- https://gitlab.example/group/project.git ") {
|
||||
t.Fatalf("git calls = %v", calls)
|
||||
}
|
||||
|
||||
// container created with the right image, env, labels, mounts, network
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 {
|
||||
t.Fatalf("agent containers created = %+v", creates)
|
||||
}
|
||||
c := creates[0]
|
||||
if c.Image != imageRefWorker {
|
||||
t.Fatalf("image = %q", c.Image)
|
||||
}
|
||||
if c.Labels[labelSession] != res.SessionID {
|
||||
t.Fatalf("labels = %v", c.Labels)
|
||||
}
|
||||
wantEnv := map[string]bool{
|
||||
envProviderAPIKey + "=key-123": true,
|
||||
envToken + "=" + testToken: true,
|
||||
"LVMH_URL=" + defaultContainerURL: true,
|
||||
envLVMHSessionID + "=" + res.SessionID: true,
|
||||
envLVMHAgent + "=1": true,
|
||||
envLVMHRepo + "=group/project": true,
|
||||
}
|
||||
for _, e := range c.Env {
|
||||
if !wantEnv[e] {
|
||||
t.Fatalf("unexpected env %q in %v", e, c.Env)
|
||||
}
|
||||
delete(wantEnv, e)
|
||||
}
|
||||
if len(wantEnv) != 0 {
|
||||
t.Fatalf("missing env %v", wantEnv)
|
||||
}
|
||||
wantBinds := []string{
|
||||
"lvmh-repo-group-project:" + workspaceMount,
|
||||
volumeSessions + ":" + sessionsMount,
|
||||
}
|
||||
if len(c.HostConfig.Binds) != 2 {
|
||||
t.Fatalf("binds = %v, want %v (no models file)", c.HostConfig.Binds, wantBinds)
|
||||
}
|
||||
for i, b := range wantBinds {
|
||||
if c.HostConfig.Binds[i] != b {
|
||||
t.Fatalf("binds = %v, want %v", c.HostConfig.Binds, wantBinds)
|
||||
}
|
||||
}
|
||||
if c.HostConfig.NetworkMode != defaultNetwork {
|
||||
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
|
||||
}
|
||||
|
||||
// fresh repo volume seeded from the clone via a one-shot container
|
||||
seed := f.createsByName("lvmh-seed-")
|
||||
if len(seed) != 1 {
|
||||
t.Fatalf("seed containers = %+v", seed)
|
||||
}
|
||||
if seed[0].Image != imageRefWorker || len(seed[0].Cmd) == 0 || !strings.Contains(seed[0].Cmd[len(seed[0].Cmd)-1], workspaceMount) {
|
||||
t.Fatalf("seed create = %+v", seed[0])
|
||||
}
|
||||
if !strings.HasPrefix(seed[0].HostConfig.Binds[1], sp.reposDir) {
|
||||
t.Fatalf("seed binds = %v, want clone dir mounted at /src", seed[0].HostConfig.Binds)
|
||||
}
|
||||
|
||||
// no build expected (fake daemon already has the image)
|
||||
if f.hasCall(http.MethodPost, "/build") {
|
||||
t.Fatal("image present but build was called")
|
||||
}
|
||||
// session volume created too
|
||||
if !f.volumeExists(volumeSessions) {
|
||||
t.Fatalf("volume %q missing", volumeSessions)
|
||||
}
|
||||
|
||||
// container row persisted
|
||||
row, ok, err := store.GetContainer(res.SessionID)
|
||||
if err != nil || !ok || row.Repo != "group/project" {
|
||||
t.Fatalf("container row = %+v ok=%v err=%v", row, ok, err)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.images = 0 // image absent → ensureImage must build
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
|
||||
builds := f.countCalls(http.MethodPost, "/build")
|
||||
if builds != 1 {
|
||||
t.Fatalf("build calls = %d, want 1", builds)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.images = 0
|
||||
sp, _ := newTestSpawner(t, f) // dockerfile exists in the fake context
|
||||
|
||||
// docker reachable but image absent and no Dockerfile anywhere → clear error
|
||||
t.Setenv(envWorkerDockerfile, filepath.Join(t.TempDir(), "missing.Dockerfile"))
|
||||
sp2, err := NewSpawner(sp.store, sp.hub, "https://gitlab.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
_, err = sp2.Start(context.Background(), "group/project", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
|
||||
t.Fatalf("Start without dockerfile err = %v", err)
|
||||
}
|
||||
if len(sp2.JobsSnapshot()) != 0 {
|
||||
t.Fatalf("failed Start must not leave a job: %+v", sp2.JobsSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerRunJobErrorStates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, f *fakeDocker)
|
||||
gitMode string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "clone-fails",
|
||||
gitMode: fakeGitModeFail,
|
||||
wantMsg: "fatal: repository not found",
|
||||
},
|
||||
{
|
||||
name: "noisy-clone-failure-truncated-to-tail",
|
||||
gitMode: fakeGitModeNoisy,
|
||||
wantMsg: "xxx",
|
||||
},
|
||||
{
|
||||
name: "build-fails",
|
||||
setup: func(t *testing.T, f *fakeDocker) {
|
||||
f.images = 0
|
||||
f.failBuild = true
|
||||
},
|
||||
wantMsg: "build exploded",
|
||||
},
|
||||
{
|
||||
name: "create-fails",
|
||||
setup: func(t *testing.T, f *fakeDocker) {
|
||||
f.failCreate = true
|
||||
},
|
||||
wantMsg: "create failed",
|
||||
},
|
||||
{
|
||||
name: "start-fails-and-cleans-up",
|
||||
setup: func(t *testing.T, f *fakeDocker) {
|
||||
f.failStart = true
|
||||
},
|
||||
wantMsg: "start failed",
|
||||
},
|
||||
{
|
||||
name: "volume-create-fails",
|
||||
setup: func(t *testing.T, f *fakeDocker) {
|
||||
f.failVolumeCreate = true
|
||||
},
|
||||
wantMsg: "volume create failed",
|
||||
},
|
||||
{
|
||||
name: "seed-wait-fails",
|
||||
setup: func(t *testing.T, f *fakeDocker) {
|
||||
f.failWait = true
|
||||
},
|
||||
wantMsg: "wait failed",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gitMode := tc.gitMode
|
||||
if gitMode == "" {
|
||||
gitMode = fakeGitModeOK
|
||||
}
|
||||
useFakeGit(t, gitMode)
|
||||
f := newFakeDocker()
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, f)
|
||||
}
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
job := waitJobState(t, sp, res.SessionID, stateError)
|
||||
if !strings.Contains(job.Message, tc.wantMsg) {
|
||||
t.Fatalf("job message = %q, want containing %q", job.Message, tc.wantMsg)
|
||||
}
|
||||
if job.ContainerID != "" {
|
||||
t.Fatalf("error job has containerId %q", job.ContainerID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
|
||||
gitLog := useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
// existing clone → pull --ff-only in that dir, no clone
|
||||
dir := filepath.Join(sp.reposDir, "group-project")
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir .git: %v", err)
|
||||
}
|
||||
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err != nil {
|
||||
t.Fatalf("cloneOrUpdate: %v", err)
|
||||
}
|
||||
calls := readGitLog(t, gitLog)
|
||||
if len(calls) != 1 || calls[0] != "pull --ff-only" {
|
||||
t.Fatalf("git calls = %v, want pull --ff-only", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
|
||||
if u, err := sp.cloneURL("group/project"); err != nil || u != "https://gitlab.example/group/project.git" {
|
||||
t.Fatalf("cloneURL without PAT = %q err=%v", u, err)
|
||||
}
|
||||
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
|
||||
t.Fatalf("set token: %v", err)
|
||||
}
|
||||
u, err := sp.cloneURL("group/project")
|
||||
if err != nil {
|
||||
t.Fatalf("cloneURL: %v", err)
|
||||
}
|
||||
if u != "https://oauth2:pat-1@gitlab.example/group/project.git" {
|
||||
t.Fatalf("cloneURL with PAT = %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerModelsBindWhenPresent(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
models := filepath.Join(t.TempDir(), "models.json")
|
||||
if err := os.WriteFile(models, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("write models: %v", err)
|
||||
}
|
||||
sp.modelsPath = models
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 {
|
||||
t.Fatalf("creates = %d", len(creates))
|
||||
}
|
||||
roFound := false
|
||||
for _, b := range creates[0].HostConfig.Binds {
|
||||
if b == models+":"+modelsMountTarget+":ro" {
|
||||
roFound = true
|
||||
}
|
||||
}
|
||||
if !roFound {
|
||||
t.Fatalf("models read-only bind missing: %v", creates[0].HostConfig.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerRemoveSession(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
|
||||
if err := sp.RemoveSession(context.Background(), "nope"); err != errNoContainer {
|
||||
t.Fatalf("remove unknown = %v, want errNoContainer", err)
|
||||
}
|
||||
|
||||
if err := store.UpsertContainer("s1", "cid-9", "group/project"); err != nil {
|
||||
t.Fatalf("upsert container: %v", err)
|
||||
}
|
||||
sp.setJob("s1", "group/project", stateRunning, "cid-9", "")
|
||||
if err := sp.RemoveSession(context.Background(), "s1"); err != nil {
|
||||
t.Fatalf("RemoveSession: %v", err)
|
||||
}
|
||||
if !f.hasCall(http.MethodPost, "/containers/cid-9/stop") || !f.hasCall(http.MethodDelete, "/containers/cid-9") {
|
||||
t.Fatal("container not stopped+removed")
|
||||
}
|
||||
if _, ok, _ := store.GetContainer("s1"); ok {
|
||||
t.Fatal("container row must be deleted")
|
||||
}
|
||||
for _, j := range sp.JobsSnapshot() {
|
||||
if j.SessionID == "s1" && j.State != stateError {
|
||||
t.Fatalf("job after removal = %+v, want error state", j)
|
||||
}
|
||||
}
|
||||
|
||||
// stop failure surfaces, row kept
|
||||
if err := store.UpsertContainer("s2", "cid-10", "group/project"); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
f.failStop = true
|
||||
if err := sp.RemoveSession(context.Background(), "s2"); err == nil || !strings.Contains(err.Error(), "docker stop") {
|
||||
t.Fatalf("remove with stop failure = %v", err)
|
||||
}
|
||||
f.assertNoUnknown(t)
|
||||
}
|
||||
|
||||
func TestSpawnerSeedVolumeWaitContextCancel(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.waitHang = true // server never answers wait
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project") }()
|
||||
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPost, "/wait") })
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("seedVolume after cancel = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("seedVolume did not return after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
l1 := sp.slugLock("a")
|
||||
l2 := sp.slugLock("a")
|
||||
if l1 != l2 {
|
||||
t.Fatal("slugLock must return the same mutex per slug")
|
||||
}
|
||||
if sp.slugLock("b") == l1 {
|
||||
t.Fatal("slugLock must return distinct mutexes per slug")
|
||||
}
|
||||
|
||||
res1, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start 1: %v", err)
|
||||
}
|
||||
res2, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start 2: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res1.SessionID, stateRunning)
|
||||
waitJobState(t, sp, res2.SessionID, stateRunning)
|
||||
if len(f.createsByName("lvmh-agent-")) != 2 {
|
||||
t.Fatalf("agent containers = %d, want 2", len(f.createsByName("lvmh-agent-")))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSlugAndUUID(t *testing.T) {
|
||||
if got := repoSlug("a/b/c"); got != "a-b-c" {
|
||||
t.Fatalf("repoSlug = %q", got)
|
||||
}
|
||||
id := newUUID()
|
||||
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
|
||||
t.Fatalf("newUUID shape = %q", id)
|
||||
}
|
||||
if id2 := newUUID(); id2 == id {
|
||||
t.Fatal("newUUID must not repeat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOr(t *testing.T) {
|
||||
t.Setenv("LVMH_TEST_ENV_OR", " value ")
|
||||
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "value" {
|
||||
t.Fatalf("envOr trimmed = %q", got)
|
||||
}
|
||||
t.Setenv("LVMH_TEST_ENV_OR", " ")
|
||||
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "def" {
|
||||
t.Fatalf("envOr whitespace-only = %q", got)
|
||||
}
|
||||
if got := envOr("LVMH_TEST_ENV_OR_UNSET", "def"); got != "def" {
|
||||
t.Fatalf("envOr unset = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBuildError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"no-error", `{"stream":"Step 1/3"}` + "\n" + `{"stream":"done"}`, ""},
|
||||
{"error-field", `{"error":"nope"}`, "nope"},
|
||||
{"error-detail-wins", `{"errorDetail":{"message":"boom"},"error":"generic"}`, "boom"},
|
||||
{"mixed-stream", "{\"stream\":\"...\"}\n{\"error\":\"late failure\"}", "late failure"},
|
||||
{"non-json-line", "garbage line\n{\"error\":\"after garbage\"}", "after garbage"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractBuildError([]byte(tc.body)); got != tc.want {
|
||||
t.Fatalf("extractBuildError(%q) = %q, want %q", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTarDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "one.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "two.txt"), []byte("two"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tarDir(&buf, dir); err != nil {
|
||||
t.Fatalf("tarDir: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
|
||||
names := map[string]string{}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar next: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(tr)
|
||||
names[hdr.Name] = string(body)
|
||||
if hdr.Uname != "root" || hdr.Gname != "root" {
|
||||
t.Fatalf("header %q owner = %s/%s, want root/root", hdr.Name, hdr.Uname, hdr.Gname)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"one.txt": "one", "sub/two.txt": "two"} {
|
||||
if names[name] != want {
|
||||
t.Fatalf("archive missing %q (have %v)", name, names)
|
||||
}
|
||||
}
|
||||
if _, ok := names["sub/"]; !ok {
|
||||
t.Fatalf("directories must be archived with trailing slash: %v", names)
|
||||
}
|
||||
|
||||
if err := tarDir(&buf, filepath.Join(dir, "does-not-exist")); err == nil {
|
||||
t.Fatal("tarDir of missing dir must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitRunSilentFailure(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeSilent)
|
||||
err := gitRun("", "clone", "x")
|
||||
if err == nil || strings.Contains(err.Error(), "clone x") {
|
||||
// silent failure surfaces the bare exec error, not a padded message
|
||||
t.Fatalf("gitRun silent failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerNewBadDockerHost(t *testing.T) {
|
||||
t.Setenv("DOCKER_HOST", "http://")
|
||||
store := openTestStore(t)
|
||||
if _, err := NewSpawner(store, NewHub(store), "https://gitlab.example"); err == nil {
|
||||
t.Fatal("NewSpawner must fail on an unparseable DOCKER_HOST")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerStartDockerUnavailable(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
// point the spawner at a dead endpoint: last Start request kills nothing
|
||||
// because the client is already built; close the fake server instead.
|
||||
sp.cli.Close()
|
||||
closeDocker(t, sp)
|
||||
if _, err := sp.Start(context.Background(), "group/project", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||
t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// closeDocker re-aims the spawner at a closed server (dial refused).
|
||||
func closeDocker(t *testing.T, sp *Spawner) {
|
||||
t.Helper()
|
||||
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONNow(w, http.StatusInternalServerError, `{"message":"dead"}`)
|
||||
}))
|
||||
dead.Close() // listener gone: connections refused
|
||||
if err := sp.cli.Close(); err != nil {
|
||||
t.Fatalf("close docker client: %v", err)
|
||||
}
|
||||
cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String()))
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild docker client: %v", err)
|
||||
}
|
||||
sp.cli = cli
|
||||
}
|
||||
|
||||
func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
// unparseable repo path (control char) → cloneURL parse error
|
||||
if err := sp.cloneOrUpdate("bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
|
||||
t.Fatal("cloneOrUpdate with control-char repo must fail")
|
||||
}
|
||||
|
||||
// reposDir path occupied by a file → MkdirAll fails
|
||||
file := filepath.Join(t.TempDir(), "not-a-dir")
|
||||
if err := os.WriteFile(file, nil, 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
sp.reposDir = file
|
||||
if err := sp.cloneOrUpdate("group/project", "", "group-project"); err == nil {
|
||||
t.Fatal("cloneOrUpdate with file reposDir must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerEnsureImageErrors(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
f.images = 0
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
ctx := context.Background()
|
||||
|
||||
// dockerfile missing on disk
|
||||
sp.dockerfile = filepath.Join(t.TempDir(), "gone.Dockerfile")
|
||||
if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "worker Dockerfile missing") {
|
||||
t.Fatalf("ensureImage without dockerfile = %v", err)
|
||||
}
|
||||
|
||||
// dockerfile exists but cannot be made relative to the build context
|
||||
sp.dockerfile = filepath.Join(t.TempDir(), "worker.Dockerfile")
|
||||
if err := os.WriteFile(sp.dockerfile, []byte("FROM scratch"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
sp.buildContext = "relative-context"
|
||||
if err := sp.ensureImage(ctx); err == nil {
|
||||
t.Fatal("ensureImage with relative-context vs absolute dockerfile must fail")
|
||||
}
|
||||
|
||||
// tar of a missing build context fails
|
||||
sp.buildContext = filepath.Join(t.TempDir(), "no-such-context")
|
||||
if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "build context") {
|
||||
t.Fatalf("ensureImage with missing context = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.volume["lvmh-repo-group-project"] = true // repo volume exists → skip seed
|
||||
f.failVolumeCreate = true
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
job := waitJobState(t, sp, res.SessionID, stateError)
|
||||
if !strings.Contains(job.Message, volumeSessions) {
|
||||
t.Fatalf("job message = %q, want sessions volume failure", job.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
ctx := context.Background()
|
||||
|
||||
f.failCreate = true
|
||||
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
|
||||
t.Fatal("seedVolume with failing create must fail")
|
||||
}
|
||||
f.failCreate = false
|
||||
f.failStart = true
|
||||
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
|
||||
t.Fatal("seedVolume with failing start must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerStoreFailures(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
// closed store: GetContainer errors
|
||||
deadStore := openTestStore(t)
|
||||
_ = deadStore.Close()
|
||||
sp.store = deadStore
|
||||
if err := sp.RemoveSession(context.Background(), "s1"); err == nil || !strings.Contains(err.Error(), "closed") {
|
||||
t.Fatalf("RemoveSession with closed store = %v", err)
|
||||
}
|
||||
|
||||
// runJob completes container start but cannot persist the row
|
||||
sp2, _ := newTestSpawner(t, newFakeDocker())
|
||||
dead2 := openTestStore(t)
|
||||
_ = dead2.Close()
|
||||
sp2.store = dead2
|
||||
res, err := sp2.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
job := waitJobState(t, sp2, res.SessionID, stateError)
|
||||
if !strings.Contains(job.Message, "not persisted") {
|
||||
t.Fatalf("job message = %q, want persistence failure", job.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeFail)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
dir := filepath.Join(sp.reposDir, "group-project")
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir .git: %v", err)
|
||||
}
|
||||
err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project"))
|
||||
if err == nil || !strings.Contains(err.Error(), "git pull") {
|
||||
t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
||||
// repo volume pre-exists → seeding skipped → failures surface at the
|
||||
// worker container create/start instead of at the seed container.
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.volume["lvmh-repo-group-project"] = true
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
f.failCreate = true
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker create") {
|
||||
t.Fatalf("job = %+v, want docker create failure", job)
|
||||
}
|
||||
|
||||
f.failCreate = false
|
||||
f.failStart = true
|
||||
res2, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start 2: %v", err)
|
||||
}
|
||||
if job := waitJobState(t, sp, res2.SessionID, stateError); !strings.Contains(job.Message, "docker start") {
|
||||
t.Fatalf("job = %+v, want docker start failure", job)
|
||||
}
|
||||
// failed worker start must remove the created container
|
||||
if f.countCalls(http.MethodDelete, "/containers/") == 0 {
|
||||
t.Fatal("failed start must clean up the created container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerBuildHTTPErrors(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
f.images = 0
|
||||
f.failBuildHTTP = true // /build endpoint itself 500s
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker build") {
|
||||
t.Fatalf("job = %+v, want docker build failure", job)
|
||||
}
|
||||
|
||||
// dead docker endpoint: ensureImage cannot even list images
|
||||
sp2, _ := newTestSpawner(t, newFakeDocker())
|
||||
sp2.images0AndDead(t)
|
||||
if err := sp2.ensureImage(context.Background()); err == nil {
|
||||
t.Fatal("ensureImage with dead docker must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *Spawner) images0AndDead(t *testing.T) {
|
||||
t.Helper()
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
t.Cleanup(dead.Close)
|
||||
dead.Close()
|
||||
if err := sp.cli.Close(); err != nil {
|
||||
t.Fatalf("close client: %v", err)
|
||||
}
|
||||
cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String()))
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
sp.cli = cli
|
||||
}
|
||||
|
||||
// failingWriter errors on the Nth write onward.
|
||||
type failingWriter struct {
|
||||
n, failAt int
|
||||
}
|
||||
|
||||
func (w *failingWriter) Write(p []byte) (int, error) {
|
||||
w.n++
|
||||
if w.n >= w.failAt {
|
||||
return 0, fmt.Errorf("write boom %d", w.n)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestTarDirWriteErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("content"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := tarDir(&failingWriter{failAt: 1}, dir); err == nil {
|
||||
t.Fatal("tarDir into failing writer must fail")
|
||||
}
|
||||
if err := tarDir(&failingWriter{failAt: 4}, dir); err == nil {
|
||||
t.Fatal("tarDir copy into failing writer must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTarDirUnreadableFile(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores file permissions")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
secret := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(secret, 0o000); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(secret, 0o644) })
|
||||
var buf bytes.Buffer
|
||||
if err := tarDir(&buf, dir); err == nil {
|
||||
t.Fatal("tarDir with unreadable file must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitRunUnderivableExit(t *testing.T) {
|
||||
// gitRun is the exec seam: with real git present, invoking a nonexistent
|
||||
// subcommand must surface stderr, and trimming applies to long output.
|
||||
if err := gitRun("", "version"); err != nil {
|
||||
t.Fatalf("git version: %v", err)
|
||||
}
|
||||
err := gitRun("", "this-subcommand-does-not-exist")
|
||||
if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") {
|
||||
t.Fatalf("gitRun unknown subcommand = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
// store_extra_test.go — open failure, closed-handle error branches, corrupt rows.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenStoreBadPath(t *testing.T) {
|
||||
if _, err := OpenStore(filepath.Join(t.TempDir(), "occupied.db", "x.db")); err == nil {
|
||||
t.Fatal("OpenStore under a file path must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreClosedHandleErrors(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
errStr := func(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
if err := store.AppendEvent(Event{SessionID: "s", Seq: 1, Type: evAgentSettled}); errStr(err) == "" {
|
||||
t.Fatal("AppendEvent on closed store must error")
|
||||
}
|
||||
if _, err := store.LastSeq("s"); errStr(err) == "" {
|
||||
t.Fatal("LastSeq on closed store must error")
|
||||
}
|
||||
if _, err := store.EventsAfter("s", 0, 10); errStr(err) == "" {
|
||||
t.Fatal("EventsAfter on closed store must error")
|
||||
}
|
||||
if err := store.UpsertSession(SessionInfo{ID: "s"}); errStr(err) == "" {
|
||||
t.Fatal("UpsertSession on closed store must error")
|
||||
}
|
||||
if err := store.TouchSession("s", 1, 1); errStr(err) == "" {
|
||||
t.Fatal("TouchSession on closed store must error")
|
||||
}
|
||||
if err := store.SetOnline("s", true); errStr(err) == "" {
|
||||
t.Fatal("SetOnline on closed store must error")
|
||||
}
|
||||
if _, err := store.Sessions(); errStr(err) == "" {
|
||||
t.Fatal("Sessions on closed store must error")
|
||||
}
|
||||
if _, _, err := store.GetSetting("k"); errStr(err) == "" {
|
||||
t.Fatal("GetSetting on closed store must error")
|
||||
}
|
||||
if err := store.SetSetting("k", "v"); errStr(err) == "" {
|
||||
t.Fatal("SetSetting on closed store must error")
|
||||
}
|
||||
if err := store.DeleteSetting("k"); errStr(err) == "" {
|
||||
t.Fatal("DeleteSetting on closed store must error")
|
||||
}
|
||||
if err := store.UpsertContainer("s", "c", "r"); errStr(err) == "" {
|
||||
t.Fatal("UpsertContainer on closed store must error")
|
||||
}
|
||||
if _, _, err := store.GetContainer("s"); errStr(err) == "" {
|
||||
t.Fatal("GetContainer on closed store must error")
|
||||
}
|
||||
if err := store.DeleteContainer("s"); errStr(err) == "" {
|
||||
t.Fatal("DeleteContainer on closed store must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCorruptRows(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
|
||||
// undecodable info blob → Sessions errors
|
||||
if _, err := store.db.Exec(`INSERT INTO sessions(id, info) VALUES('bad', 'not json')`); err != nil {
|
||||
t.Fatalf("insert corrupt session: %v", err)
|
||||
}
|
||||
if _, err := store.Sessions(); err == nil || !strings.Contains(err.Error(), "decode session info") {
|
||||
t.Fatalf("Sessions with corrupt info = %v", err)
|
||||
}
|
||||
|
||||
// non-integer seq → EventsAfter scan error
|
||||
if _, err := store.db.Exec(`INSERT INTO events(sessionId, seq, ts, type, payload)
|
||||
VALUES('s1', 'not-a-number', 1, 'x', '{}')`); err != nil {
|
||||
t.Fatalf("insert corrupt event: %v", err)
|
||||
}
|
||||
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
|
||||
t.Fatal("EventsAfter with text seq must scan-error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventDefaultPayload(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 1, Type: evBye}); err != nil {
|
||||
t.Fatalf("append empty payload: %v", err)
|
||||
}
|
||||
events, err := store.EventsAfter("s1", 0, 10)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events = %v %v", events, err)
|
||||
}
|
||||
if string(events[0].Payload) != "{}" {
|
||||
t.Fatalf("payload = %q, want {}", events[0].Payload)
|
||||
}
|
||||
}
|
||||
+23
-5
@@ -9,24 +9,42 @@ export async function run(ctx) {
|
||||
const { r, base, token, agentUrl, webUrl } = ctx;
|
||||
|
||||
const noToken = await rest(base, null, "/api/sessions");
|
||||
r.check("REST without token → 401", noToken.status === 401, `got ${noToken.status}`);
|
||||
r.check(
|
||||
"REST without token → 401",
|
||||
noToken.status === 401,
|
||||
`got ${noToken.status}`,
|
||||
);
|
||||
|
||||
const badToken = await rest(base, "definitely-wrong", "/api/sessions");
|
||||
r.check("REST with bad token → 401", badToken.status === 401, `got ${badToken.status}`);
|
||||
r.check(
|
||||
"REST with bad token → 401",
|
||||
badToken.status === 401,
|
||||
`got ${badToken.status}`,
|
||||
);
|
||||
|
||||
const goodToken = await rest(base, token, "/api/sessions");
|
||||
r.check("REST with correct token → 200", goodToken.status === 200, `got ${goodToken.status}`);
|
||||
r.check(
|
||||
"REST with correct token → 200",
|
||||
goodToken.status === 200,
|
||||
`got ${goodToken.status}`,
|
||||
);
|
||||
|
||||
// Web WS: token arrives as ?token= query param (browsers cannot set headers).
|
||||
const badWeb = new WSSock(`${webUrl}?token=wrong-token`);
|
||||
const webOpened = await badWeb.opened();
|
||||
r.check("web WS with bad ?token= rejected before upgrade", !webOpened, "connection accepted");
|
||||
r.check(
|
||||
"web WS with bad ?token= rejected before upgrade",
|
||||
!webOpened,
|
||||
"connection accepted",
|
||||
);
|
||||
badWeb.close();
|
||||
|
||||
// Agent WS with bad bearer must be rejected: PROTOCOL.md §Auth requires
|
||||
// closing the upgrade on mismatch (fixed: Routes() wraps /agent/ws in
|
||||
// s.bearerAuth).
|
||||
const badAgent = new WSSock(agentUrl, { headers: { Authorization: `Bearer wrong-token` } });
|
||||
const badAgent = new WSSock(agentUrl, {
|
||||
headers: { Authorization: `Bearer wrong-token` },
|
||||
});
|
||||
const agentOpened = await badAgent.opened();
|
||||
let welcomed = null;
|
||||
if (agentOpened) {
|
||||
|
||||
Generated
+1363
-1
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
@@ -14,10 +16,16 @@
|
||||
"react-router-dom": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.5",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import App from "./App";
|
||||
import { clearSettings } from "./settings";
|
||||
import { FakeWebSocket, jsonResponse, mockFetchJson, seedSettings, stubReload } from "./test/setup";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
|
||||
const sessions: SessionListItem[] = [
|
||||
{ id: "s1", name: "alpha", cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: "g/a", startedAt: 10, online: true, lastEventAt: null },
|
||||
{ id: "s2", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: true, repo: "g/b", startedAt: 20, online: false, lastEventAt: null },
|
||||
// bare session: exercises null-name/null-repo fallbacks
|
||||
{ id: "s3", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 30, online: false, lastEventAt: null },
|
||||
];
|
||||
|
||||
function seedApi(): void {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
|
||||
if (url.includes("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function renderApp(path = "/"): ReturnType<typeof render> {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearSettings();
|
||||
});
|
||||
|
||||
describe("App gate", () => {
|
||||
it("shows the settings gate when unconfigured", () => {
|
||||
renderApp();
|
||||
expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Sessions")).toBeNull();
|
||||
});
|
||||
|
||||
it("after saving settings the shell renders", async () => {
|
||||
seedApi();
|
||||
renderApp();
|
||||
fireEvent.input(screen.getByLabelText("Bearer token"), { target: { value: "tok" } });
|
||||
fireEvent.submit(screen.getByRole("button", { name: "Connect" }).closest("form") as HTMLFormElement);
|
||||
await screen.findByLabelText("Sessions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("App shell", () => {
|
||||
it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => {
|
||||
seedApi();
|
||||
seedSettings();
|
||||
renderApp("/");
|
||||
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
expect(screen.getByRole("img", { name: "connection open" })).toBeInTheDocument();
|
||||
|
||||
const sidebar = screen.getByLabelText("Sessions");
|
||||
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map((a) => a.getAttribute("href"));
|
||||
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc
|
||||
expect(screen.getByTitle("ws open")).toBeInTheDocument();
|
||||
|
||||
// root route lists sessions
|
||||
expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
|
||||
|
||||
// spawn link navigates
|
||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||
await screen.findByRole("heading", { name: "Spawn" });
|
||||
|
||||
// unknown route redirects to sessions
|
||||
const { container: c2 } = renderApp("/nowhere");
|
||||
await waitFor(() => expect(c2.querySelector(".session-card, .empty")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("navigates to a session chat route", async () => {
|
||||
seedApi();
|
||||
seedSettings();
|
||||
renderApp("/s/s1");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByLabelText("Message");
|
||||
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("menu button toggles the sidebar and it closes on navigation", async () => {
|
||||
seedApi();
|
||||
seedSettings();
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByRole("heading", { name: "Sessions" });
|
||||
|
||||
const menu = screen.getByLabelText("Open menu");
|
||||
fireEvent.click(menu);
|
||||
expect(screen.getByLabelText("Sessions").className).toContain("open");
|
||||
|
||||
// backdrop closes it
|
||||
fireEvent.click(container_backdrop()!);
|
||||
expect(screen.getByLabelText("Sessions").className).not.toContain("open");
|
||||
|
||||
fireEvent.click(menu);
|
||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||
await waitFor(() => expect(screen.getByLabelText("Sessions").className).not.toContain("open"));
|
||||
});
|
||||
|
||||
it("disconnect clears settings and reloads", async () => {
|
||||
const loc = stubReload();
|
||||
seedApi();
|
||||
seedSettings();
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByRole("heading", { name: "Sessions" });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Disconnect and clear settings"));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
expect(localStorage.getItem("lvmh.settings")).toBeNull();
|
||||
loc.restore();
|
||||
});
|
||||
|
||||
it("ws auth failure clears settings and re-gates", async () => {
|
||||
const loc = stubReload();
|
||||
seedApi();
|
||||
seedSettings();
|
||||
renderApp("/");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
act(() => FakeWebSocket.last().serverOpen());
|
||||
await screen.findByRole("heading", { name: "Sessions" });
|
||||
|
||||
act(() => FakeWebSocket.last().serverClose(1008));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument());
|
||||
loc.restore();
|
||||
});
|
||||
|
||||
it("REST seed failure surfaces a toast", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/api/sessions") && !url.includes("/events")) return jsonResponse({ error: "seed fail" }, 500);
|
||||
return [];
|
||||
});
|
||||
seedSettings();
|
||||
renderApp("/");
|
||||
await screen.findByText("sessions: seed fail");
|
||||
});
|
||||
});
|
||||
|
||||
function container_backdrop(): HTMLElement | null {
|
||||
return document.querySelector(".sidebar-backdrop");
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ToolState } from "./derive";
|
||||
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
return {
|
||||
key: `k-${Math.random()}`,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
id: "c1",
|
||||
name: "bash",
|
||||
args: "",
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("Bubble", () => {
|
||||
it("renders plain text per role class", () => {
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />
|
||||
);
|
||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||
expect(container.textContent).toContain("hi there");
|
||||
});
|
||||
|
||||
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||
const { container } = render(<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />);
|
||||
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||
const long = `${"x".repeat(200)}`;
|
||||
render(<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />);
|
||||
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
|
||||
expect(details.open).toBe(false);
|
||||
expect(details.textContent).toContain("…");
|
||||
await userEvent.click(screen.getByText("result"));
|
||||
expect(details.open).toBe(true);
|
||||
expect(details.textContent).toContain(long);
|
||||
});
|
||||
|
||||
it("toolResult with short text keeps full one-line preview", () => {
|
||||
render(<Bubble msg={msg({ role: "toolResult", text: "short out" })} tools={new Map()} />);
|
||||
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
|
||||
expect(details.textContent).toContain("short out");
|
||||
expect(details.textContent).not.toContain("…");
|
||||
});
|
||||
|
||||
it("flattens whitespace in previews", () => {
|
||||
render(<Bubble msg={msg({ role: "toolResult", text: "a\n\n b c" })} tools={new Map()} />);
|
||||
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("assistant tool calls attach tool cards", async () => {
|
||||
const tools = new Map<string, ToolState>([
|
||||
["c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file" })],
|
||||
]);
|
||||
render(
|
||||
<Bubble msg={msg({ role: "assistant", text: "finished", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] })} tools={tools} />
|
||||
);
|
||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||
|
||||
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement;
|
||||
const card = summary.closest("details") as HTMLDetailsElement;
|
||||
expect(card.open).toBe(false);
|
||||
await userEvent.click(summary);
|
||||
expect(card.open).toBe(true);
|
||||
expect(card.textContent).toContain("ls -la");
|
||||
expect(card.textContent).toContain("file");
|
||||
});
|
||||
|
||||
it("tool card status variants: running, error, done", () => {
|
||||
const tools = new Map<string, ToolState>([
|
||||
["c1", tool({ id: "c1", running: true })],
|
||||
["c2", tool({ id: "c2", running: false, isError: true })],
|
||||
["c3", tool({ id: "c3", running: false, isError: false })],
|
||||
]);
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
role: "assistant",
|
||||
toolCalls: ["c1", "c2", "c3"].map((id) => ({ id, name: `t-${id}`, argsJson: "{}" })),
|
||||
})}
|
||||
tools={tools}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
||||
expect(screen.getByText("error")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tool call with no matching state renders no card", () => {
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ role: "assistant", toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }] })} tools={new Map()} />
|
||||
);
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("thinking block only for non-empty thinking", async () => {
|
||||
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
||||
const details = screen.getByText("thinking").closest("details") as HTMLDetailsElement;
|
||||
await userEvent.click(screen.getByText("thinking"));
|
||||
expect(details.open).toBe(true);
|
||||
expect(details.textContent).toContain("because");
|
||||
|
||||
const { container } = render(<Bubble msg={msg({ thinking: null })} tools={new Map()} />);
|
||||
expect(container.querySelector(".thinking")).toBeNull();
|
||||
});
|
||||
|
||||
it("streaming bubble shows the caret", () => {
|
||||
const { container } = render(<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />);
|
||||
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypingIndicator", () => {
|
||||
it("renders three dots with aria-live", () => {
|
||||
const { container } = render(<TypingIndicator />);
|
||||
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatStream", () => {
|
||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||
const { container, rerender } = render(
|
||||
<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy />
|
||||
);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ChatStream messages={[msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true })]} tools={new Map()} busy />
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy={false} />);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||
const { container, rerender } = render(<ChatStream messages={[msg({ key: "a" })]} tools={new Map()} busy={false} />);
|
||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300 });
|
||||
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
// pinned: scrollTop at bottom
|
||||
scroller.scrollTop = 700;
|
||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700 });
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" })]} tools={new Map()} busy={false} />);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
|
||||
// scroll far up -> unpin
|
||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]} tools={new Map()} busy={false} />);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
|
||||
// scroll near bottom (within 80px) -> pinned again
|
||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940 });
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" }), msg({ key: "d" })]} tools={new Map()} busy={false} />);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EventFrame } from "./protocol";
|
||||
import ChatView from "./ChatView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
|
||||
let seq: number = 0;
|
||||
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
|
||||
seq += 1;
|
||||
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
|
||||
}
|
||||
|
||||
const sessions: SessionListItem[] = [
|
||||
{
|
||||
id: "s1",
|
||||
name: "worker",
|
||||
cwd: "/w",
|
||||
model: "glm-5.3",
|
||||
provider: "zai-renaud",
|
||||
agent: false,
|
||||
repo: null,
|
||||
startedAt: 0,
|
||||
online: true,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||
return {
|
||||
sessions,
|
||||
state: "open",
|
||||
spawnJobs: [],
|
||||
refresh: async () => undefined,
|
||||
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
|
||||
currentSub = { sessionId, onEvents };
|
||||
return () => {
|
||||
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null;
|
||||
};
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveSub {
|
||||
sessionId: string;
|
||||
onEvents: (events: EventFrame[]) => void;
|
||||
}
|
||||
let currentSub: ActiveSub | null = null;
|
||||
|
||||
function push(events: EventFrame[]): void {
|
||||
act(() => currentSub?.onEvents(events));
|
||||
}
|
||||
|
||||
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
<Route path="*" element={<div>OTHER</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const pushToast = vi.fn();
|
||||
|
||||
function historyEvents(): EventFrame[] {
|
||||
seq = 0;
|
||||
return [
|
||||
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_start"),
|
||||
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_settled"),
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seq = 0;
|
||||
currentSub = null;
|
||||
pushToast.mockClear();
|
||||
seedSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatView", () => {
|
||||
it("renders history from REST on mount and subscribes to the WS stream", async () => {
|
||||
const fetchMock = mockFetchJson((url) => {
|
||||
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { rerender } = renderChat(store);
|
||||
|
||||
await screen.findByText("hello there");
|
||||
expect(screen.getByText("hi!")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
expect(currentSub?.sessionId).toBe("s1");
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
// header shows session name + model + online dot
|
||||
expect(screen.getByText("worker")).toBeInTheDocument();
|
||||
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("online")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
<Route path="*" element={<div>OTHER</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
it("live delta streaming appends into a streaming bubble and ends it", async () => {
|
||||
mockFetchJson(() => []);
|
||||
renderChat(makeStore());
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
|
||||
push([
|
||||
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
]);
|
||||
push([ev("message_update", { delta: "Hel" })]);
|
||||
push([ev("message_update", { delta: "lo" })]);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Send message")).toBeNull();
|
||||
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
|
||||
|
||||
push([
|
||||
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_settled"),
|
||||
]);
|
||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("typing indicator shows while busy with no open stream", async () => {
|
||||
mockFetchJson(() => []);
|
||||
const { container } = renderChat(makeStore());
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
|
||||
push([ev("agent_start")]);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
push([ev("agent_settled")]);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
it("409 on send toasts 'session offline'", async () => {
|
||||
let n = 0;
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "POST") {
|
||||
n += 1;
|
||||
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
const ta = screen.getByLabelText("Message");
|
||||
await userEvent.type(ta, "go");
|
||||
await userEvent.click(screen.getByLabelText("Send message"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"));
|
||||
});
|
||||
|
||||
it("non-409 send failure toasts the error message", async () => {
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
await userEvent.type(screen.getByLabelText("Message"), "go");
|
||||
await userEvent.click(screen.getByLabelText("Send message"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
||||
});
|
||||
|
||||
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
||||
renderChat(makeStore());
|
||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
await userEvent.type(ta, "hello");
|
||||
expect(send).not.toBeDisabled();
|
||||
|
||||
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
|
||||
fireEvent.click(send);
|
||||
await userEvent.clear(ta);
|
||||
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
|
||||
await vi.waitFor(() => {
|
||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST");
|
||||
expect(post).toBeDefined();
|
||||
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" }));
|
||||
});
|
||||
expect(ta.value).toBe("");
|
||||
});
|
||||
|
||||
it("abort posts to the abort route", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
||||
renderChat(makeStore());
|
||||
push([ev("agent_start")]);
|
||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||
await vi.waitFor(() => {
|
||||
const abortCall = fetchMock.mock.calls.find(
|
||||
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort")
|
||||
);
|
||||
expect(abortCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("abort failure toasts", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
push([ev("agent_start")]);
|
||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"));
|
||||
});
|
||||
|
||||
it("history load failure shows the error page with a back link", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refetches missed persisted events when the ws (re)opens", async () => {
|
||||
let after = "";
|
||||
mockFetchJson((url) => {
|
||||
const m = /[?&]after=(\d+)/.exec(url);
|
||||
if (m !== null) after = m[1] ?? "";
|
||||
if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore({ state: "closed" });
|
||||
const { rerender } = render(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByText("caught up");
|
||||
expect(after).toBe("0");
|
||||
|
||||
// reconnect: state closed -> open triggers the after=N refetch
|
||||
act(() => {
|
||||
rerender(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => expect(after).toBe("1"));
|
||||
});
|
||||
|
||||
it("task panel toggle and tool render in both the aside and mobile view", async () => {
|
||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||
const { container } = renderChat(makeStore());
|
||||
|
||||
// running tool start with no end shows in the Working section
|
||||
await screen.findByText("hello there");
|
||||
const toggle = screen.getByLabelText("Toggle task panel");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(toggle);
|
||||
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
|
||||
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("no session id param renders the empty page", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/s/"]}>
|
||||
<Routes>
|
||||
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} />
|
||||
<Route path="/s/:id" element={<div>CHAT</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText("No session selected.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useParams } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
import SessionsView from "./SessionsView";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
|
||||
function session(p: Partial<SessionListItem>): SessionListItem {
|
||||
return {
|
||||
id: "s1",
|
||||
name: null,
|
||||
cwd: "/w",
|
||||
model: "glm-5.3",
|
||||
provider: "zai-renaud",
|
||||
agent: false,
|
||||
repo: null,
|
||||
startedAt: 100,
|
||||
online: false,
|
||||
lastEventAt: null,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(props: Partial<Parameters<typeof SessionsView>[0]> = {}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<SessionsView sessions={[]} onChanged={() => undefined} pushToast={() => undefined} {...props} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
||||
const { id } = useParams();
|
||||
onVisit(`/s/${id ?? ""}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("SessionsView", () => {
|
||||
it("empty state message", () => {
|
||||
renderView();
|
||||
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cards sorted by last activity with fallbacks", () => {
|
||||
renderView({
|
||||
sessions: [
|
||||
session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }),
|
||||
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
|
||||
session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }),
|
||||
],
|
||||
});
|
||||
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
||||
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
||||
"Open session c",
|
||||
"Open session named",
|
||||
"Open session a",
|
||||
]);
|
||||
expect(cards[0]?.textContent).toContain("/fallback");
|
||||
expect(screen.getAllByTitle("online")).toHaveLength(1);
|
||||
expect(screen.getAllByTitle("offline")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows repo, model, relative time and agent badge", () => {
|
||||
renderView({ sessions: [session({ id: "s1", name: "named", repo: "g/p", lastEventAt: Date.now() - 5000, agent: true })] });
|
||||
expect(screen.getAllByText("g/p")).toHaveLength(1);
|
||||
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
||||
expect(screen.getByText("just now")).toBeInTheDocument();
|
||||
expect(screen.getByText("agent")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no badge/stop for non-agent sessions", () => {
|
||||
renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] });
|
||||
expect(screen.queryByText("agent")).toBeNull();
|
||||
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keyboard Enter and Space open the session; other keys ignored", () => {
|
||||
const probe = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SessionsView sessions={[session({ id: "s9", name: "kb" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
|
||||
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const card = screen.getByRole("button", { name: "Open session kb" });
|
||||
fireEvent.keyDown(card, { key: "Tab" });
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(card, { key: "Enter" });
|
||||
expect(probe).toHaveBeenCalledWith("/s/s9");
|
||||
});
|
||||
|
||||
it("Space key opens the session", () => {
|
||||
const probe = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SessionsView sessions={[session({ id: "s8" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
|
||||
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " });
|
||||
expect(probe).toHaveBeenCalledWith("/s/s8");
|
||||
});
|
||||
|
||||
it("stop with unnamed session toasts the id", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => ({ ok: true }));
|
||||
const pushToast = vi.fn();
|
||||
renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined });
|
||||
fireEvent.click(screen.getByLabelText("Stop container for s1"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1"));
|
||||
});
|
||||
|
||||
it("stop button deletes container, toasts and refreshes", async () => {
|
||||
seedSettings();
|
||||
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
||||
const onChanged = vi.fn();
|
||||
const pushToast = vi.fn();
|
||||
renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast });
|
||||
fireEvent.click(screen.getByLabelText("Stop container for worker"));
|
||||
await vi.waitFor(() => {
|
||||
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
|
||||
expect(call[1].method).toBe("DELETE");
|
||||
});
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker"));
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stop failure toasts the error", async () => {
|
||||
seedSettings();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500));
|
||||
const pushToast = vi.fn();
|
||||
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
|
||||
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: nope"));
|
||||
});
|
||||
|
||||
it("non-error stop failure path stringifies non-Error throws", async () => {
|
||||
seedSettings();
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
|
||||
const pushToast = vi.fn();
|
||||
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
|
||||
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSettings } from "./settings";
|
||||
import SettingsGate from "./SettingsGate";
|
||||
import { jsonResponse } from "./test/setup";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function setup(): { connect: () => Promise<void> } {
|
||||
render(<SettingsGate onSaved={() => undefined} />);
|
||||
return {
|
||||
connect: async (): Promise<void> => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsGate", () => {
|
||||
it("defaults the server url to the current origin", () => {
|
||||
setup();
|
||||
const input = screen.getByLabelText("Server URL") as HTMLInputElement;
|
||||
expect(input.value).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("requires a token", async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("token required");
|
||||
expect(getSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it("validation failure shows the server error and does not save", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401));
|
||||
setup();
|
||||
await userEvent.type(screen.getByLabelText("Bearer token"), "bad");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("connection failed (401)");
|
||||
expect(getSettings()).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/api/sessions",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer bad" } })
|
||||
);
|
||||
});
|
||||
|
||||
it("network rejection surfaces the thrown message", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
||||
setup();
|
||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("fetch failed");
|
||||
});
|
||||
|
||||
it("saves normalized url + trimmed token and calls onSaved", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
|
||||
const onSaved = vi.fn();
|
||||
render(<SettingsGate onSaved={onSaved} />);
|
||||
await userEvent.clear(screen.getByLabelText("Server URL"));
|
||||
await userEvent.type(screen.getByLabelText("Server URL"), "http://daemon:8686///");
|
||||
await userEvent.type(screen.getByLabelText("Bearer token"), " tok ");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://daemon:8686/api/sessions", expect.anything());
|
||||
expect(getSettings()).toEqual({ serverUrl: "http://daemon:8686", token: "tok" });
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("non-Error rejections stringify via String(err)", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never);
|
||||
setup();
|
||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("plain-string");
|
||||
});
|
||||
|
||||
it("empty server url falls back to the current origin", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
|
||||
setup();
|
||||
await userEvent.clear(screen.getByLabelText("Server URL"));
|
||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/sessions", expect.anything());
|
||||
expect(getSettings()?.serverUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Repo } from "./protocol";
|
||||
import SpawnView from "./SpawnView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
|
||||
const repo = (path: string, branch = "main"): Repo => ({
|
||||
path,
|
||||
name: path.split("/")[1] ?? path,
|
||||
namespace: path.split("/")[0] ?? "g",
|
||||
lastActivityAt: "2024-05-01T00:00:00Z",
|
||||
webUrl: `https://gl/${path}`,
|
||||
defaultBranch: branch,
|
||||
});
|
||||
|
||||
const PUSH_TOAST = vi.fn();
|
||||
|
||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||
return {
|
||||
sessions: [],
|
||||
state: "open",
|
||||
spawnJobs: [],
|
||||
refresh: async (): Promise<void> => undefined,
|
||||
subscribe: (): (() => void) => () => undefined,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(store: SessionsStore): React.ReactElement {
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/new"]}>
|
||||
<Routes>
|
||||
<Route path="/new" element={<SpawnView store={store} pushToast={PUSH_TOAST} />} />
|
||||
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flush pending microtasks + React effects (works under fake timers). */
|
||||
async function flush(ticks = 4): Promise<void> {
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
PUSH_TOAST.mockClear();
|
||||
seedSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SpawnView status", () => {
|
||||
it("shows checking state, then error when gitlab status fails", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/api/gitlab/status")) return jsonResponse({ error: "down" }, 500);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
||||
await flush();
|
||||
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView connect flow", () => {
|
||||
it("requires a token", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
expect(screen.getByText("token required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("connects, clears the PAT, loads repos and shows the picker", async () => {
|
||||
let connected = false;
|
||||
mockFetchJson((url, init) => {
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected, baseUrl: "https://gl", username: connected ? "alice" : undefined };
|
||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
|
||||
connected = true;
|
||||
return { username: "alice" };
|
||||
}
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||
|
||||
const pat = screen.getByLabelText("GitLab personal access token") as HTMLInputElement;
|
||||
fireEvent.input(pat, { target: { value: "glpat-x" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
|
||||
expect(screen.getByText("g/one")).toBeInTheDocument();
|
||||
expect(screen.getByText("g/two")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
|
||||
expect(screen.getByText("Repository")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("connect failure shows the error and keeps the gate", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") return jsonResponse({ error: "bad pat" }, 401);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.input(screen.getByLabelText("GitLab personal access token"), { target: { value: "glpat-bad" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
expect(screen.getByText("bad pat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("connected on load fetches repos immediately", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/quick")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView repo picker", () => {
|
||||
function connectedMock(): void {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha"), repo("g/beta", "dev")];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||
|
||||
const filter = screen.getByLabelText("Filter repositories");
|
||||
fireEvent.input(filter, { target: { value: "beta" } });
|
||||
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||
|
||||
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
|
||||
fireEvent.keyDown(item, { key: "Enter" });
|
||||
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||
|
||||
fireEvent.input(filter, { target: { value: "zzz" } });
|
||||
expect(screen.getByText("no matching repos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("spawn without selection shows pick-a-repo error", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
||||
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView spawn+poll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("spawns, polls until online, then navigates to the chat", async () => {
|
||||
const posts: Array<[string, RequestInit | undefined]> = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
posts.push([url, init]);
|
||||
return { sessionId: "new-1", containerId: "abc123def456" };
|
||||
}
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { unmount } = render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/proj"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
|
||||
expect(posts).toHaveLength(1);
|
||||
expect((posts[0]?.[1] as RequestInit).body).toBe(JSON.stringify({ repo: "g/proj", branch: "main" }));
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
||||
expect(screen.getByText("waiting for session to come online…")).toBeInTheDocument();
|
||||
|
||||
// first tick: session not online yet
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(screen.queryByTestId("chat-route")).toBeNull();
|
||||
|
||||
// session comes online -> next tick navigates
|
||||
store.sessions = [
|
||||
{
|
||||
id: "new-1",
|
||||
name: "spawned",
|
||||
cwd: "/w",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
agent: true,
|
||||
repo: "g/proj",
|
||||
startedAt: 1,
|
||||
online: true,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
];
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
bodies.push(String(init.body));
|
||||
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
||||
}
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
||||
return [];
|
||||
});
|
||||
const failingRefresh = makeStore({
|
||||
refresh: async (): Promise<void> => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
render(tree(failingRefresh));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/blank"));
|
||||
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
||||
});
|
||||
|
||||
it("spawn POST failure shows the error", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) return jsonResponse({ error: "no docker" }, 500);
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/x"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("no docker")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("polling gives up after the tick cap and stays on the page", async () => {
|
||||
let statusCalls = 0;
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/spawn/status")) {
|
||||
statusCalls += 1;
|
||||
return [];
|
||||
}
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
||||
if (url.endsWith("/api/spawn")) return { sessionId: "slow-1", containerId: "d" };
|
||||
return [];
|
||||
});
|
||||
const { unmount } = render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/slow"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500 * 402);
|
||||
});
|
||||
await flush();
|
||||
const afterCap = statusCalls;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500 * 10);
|
||||
});
|
||||
await flush();
|
||||
expect(statusCalls).toBe(afterCap);
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("spawn job line renders from store.spawnJobs", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) return { sessionId: "sj-1", containerId: "cid" };
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { rerender } = render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
|
||||
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
||||
act(() => {
|
||||
rerender(tree(store));
|
||||
});
|
||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskDerivation } from "./derive";
|
||||
import TaskPanel from "./TaskPanel";
|
||||
|
||||
const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] };
|
||||
|
||||
describe("TaskPanel", () => {
|
||||
it("empty state", () => {
|
||||
render(<TaskPanel tasks={base} />);
|
||||
expect(screen.getByText("No tasks yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("todo rows render icons per status and deleted styling", () => {
|
||||
const tasks: TaskDerivation = {
|
||||
...base,
|
||||
todos: [
|
||||
{ content: "write tests", status: "pending", deleted: false },
|
||||
{ content: "run them", status: "in-progress", deleted: false },
|
||||
{ content: "ship it", status: "completed", deleted: false },
|
||||
{ content: "old task", status: "pending", deleted: true },
|
||||
],
|
||||
};
|
||||
const { container } = render(<TaskPanel tasks={tasks} />);
|
||||
expect(container.querySelector(".todo-icon.pending")?.textContent).toBe("○");
|
||||
expect(container.querySelector(".todo-icon.in-progress")?.textContent).toBe("◺");
|
||||
expect(container.querySelector(".todo-icon.completed")?.textContent).toBe("●");
|
||||
const deleted = container.querySelector(".todo-item.deleted .todo-text") as HTMLElement;
|
||||
expect(deleted).not.toBeNull();
|
||||
expect(deleted.style.textDecoration).toContain("line-through");
|
||||
});
|
||||
|
||||
it("subagent rows: running spinner, done check, failed cross", () => {
|
||||
const tasks: TaskDerivation = {
|
||||
...base,
|
||||
subagents: [
|
||||
{ key: "a", name: "scout", running: true, isError: false },
|
||||
{ key: "b", name: "worker", running: false, isError: false },
|
||||
{ key: "c", name: "reviewer", running: false, isError: true },
|
||||
],
|
||||
};
|
||||
render(<TaskPanel tasks={tasks} />);
|
||||
expect(screen.getByLabelText("running")).toBeInTheDocument();
|
||||
expect(screen.getByText("scout")).toBeInTheDocument();
|
||||
expect(screen.getByText("running")).toBeInTheDocument();
|
||||
expect(screen.getByText("✓")).toBeInTheDocument();
|
||||
expect(screen.getByText("✕")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("working tools section", () => {
|
||||
const tasks: TaskDerivation = {
|
||||
...base,
|
||||
workingTools: [{ id: "c1", name: "bash", args: "ls", running: true, isError: false, preview: "" }],
|
||||
};
|
||||
render(<TaskPanel tasks={tasks} />);
|
||||
expect(screen.getByText("Working")).toBeInTheDocument();
|
||||
expect(screen.getByText("bash…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sections appear only when populated", () => {
|
||||
const { rerender, queryByText } = render(<TaskPanel tasks={{ ...base, todos: [{ content: "t", status: "pending", deleted: false }] }} />);
|
||||
expect(queryByText("Tasks")).not.toBeNull();
|
||||
expect(queryByText("Subagents")).toBeNull();
|
||||
expect(queryByText("Working")).toBeNull();
|
||||
rerender(<TaskPanel tasks={{ ...base, subagents: [{ key: "a", name: "s", running: false, isError: false }] }} />);
|
||||
expect(queryByText("Tasks")).toBeNull();
|
||||
expect(queryByText("Subagents")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, errMessage, fetchJson } from "./api";
|
||||
import { saveSettings } from "./settings";
|
||||
import { jsonResponse } from "./test/setup";
|
||||
|
||||
describe("api", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
saveSettings({ serverUrl: "http://srv", token: "sekret" });
|
||||
});
|
||||
|
||||
it("throws 401 when not configured", async () => {
|
||||
localStorage.clear();
|
||||
await expect(fetchJson("/api/sessions")).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("GET sends bearer header and parses JSON", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([{ id: "s1" }]));
|
||||
const out = await fetchJson<Array<{ id: string }>>("/api/sessions");
|
||||
expect(out).toEqual([{ id: "s1" }]);
|
||||
const [input, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(input).toBe("http://srv/api/sessions");
|
||||
expect(init.headers).toEqual({ Authorization: "Bearer sekret" });
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("POST sends JSON content-type with body", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true }));
|
||||
await fetchJson("/api/sessions/s1/prompt", { method: "POST", body: JSON.stringify({ message: "hi" }) });
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({ Authorization: "Bearer sekret", "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
it("throws ApiError with server error message", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500));
|
||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as ApiError).message).toBe("boom");
|
||||
expect((err as ApiError).status).toBe(500);
|
||||
});
|
||||
|
||||
it("falls back to status text when body has no error string", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ other: 1 }, 404));
|
||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
||||
expect((err as ApiError).message).toBe("404 StatusText");
|
||||
});
|
||||
|
||||
it("falls back to status text when body is not JSON", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
} as unknown as Response);
|
||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
||||
expect((err as ApiError).message).toBe("502 Bad Gateway");
|
||||
});
|
||||
|
||||
it("rejects null JSON bodies gracefully in error path", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409));
|
||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
||||
expect((err as ApiError).message).toBe("409 StatusText");
|
||||
});
|
||||
|
||||
it("errMessage maps Error and non-Error values", () => {
|
||||
expect(errMessage(new Error("oops"))).toBe("oops");
|
||||
expect(errMessage(42)).toBe("42");
|
||||
expect(errMessage(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EventFrame, Message } from "./protocol";
|
||||
import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive";
|
||||
|
||||
let seq: number = 0;
|
||||
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
|
||||
seq += 1;
|
||||
return { v: 1, sessionId: "s1", seq: partial.seq ?? seq, ts: 0, ...partial } as EventFrame;
|
||||
}
|
||||
function msg(m: Partial<Message>): Message {
|
||||
return {
|
||||
role: "assistant",
|
||||
id: "m1",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
...m,
|
||||
};
|
||||
}
|
||||
function toolStart(id: string, name: string, args?: string): EventFrame {
|
||||
return ev({ type: "tool_execution_start", toolCallId: id, toolName: name, args: args ?? "" });
|
||||
}
|
||||
function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
|
||||
return ev({ type: "tool_execution_end", toolCallId: id, isError: isError ?? false, resultPreview: preview ?? "" });
|
||||
}
|
||||
|
||||
// ---------- mergeEvents / lastPersistedSeq ----------
|
||||
|
||||
describe("mergeEvents", () => {
|
||||
it("merges and sorts by seq, dedupes by seq", () => {
|
||||
const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })];
|
||||
seq = 0;
|
||||
const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })];
|
||||
const merged = mergeEvents(a, b);
|
||||
expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it("incoming wins on duplicate seq", () => {
|
||||
const a = [ev({ type: "hello", seq: 1, reason: "a" })];
|
||||
const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" }));
|
||||
expect(mergeEvents(a, b)[0]?.reason).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastPersistedSeq", () => {
|
||||
it("ignores message_update deltas", () => {
|
||||
const events = [
|
||||
ev({ type: "message_end", seq: 7 }),
|
||||
ev({ type: "message_update", seq: 99 }),
|
||||
ev({ type: "agent_settled", seq: 8 }),
|
||||
];
|
||||
expect(lastPersistedSeq(events)).toBe(8);
|
||||
});
|
||||
|
||||
it("returns 0 for empty or delta-only streams", () => {
|
||||
expect(lastPersistedSeq([])).toBe(0);
|
||||
expect(lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- deriveChat ----------
|
||||
|
||||
describe("deriveChat", () => {
|
||||
it("message_end renders user/assistant/system/toolResult messages", () => {
|
||||
const events = [
|
||||
ev({ type: "message_end", message: msg({ id: "u1", role: "user", text: "hi" }) }),
|
||||
ev({ type: "message_end", message: msg({ id: "a1", role: "assistant", text: "hello" }) }),
|
||||
ev({ type: "message_end", message: msg({ id: "t1", role: "toolResult", text: "out", toolCallId: "c1" }) }),
|
||||
ev({ type: "message_end", message: msg({ id: "s1", role: "system", text: "sys" }) }),
|
||||
];
|
||||
const { messages } = deriveChat(events);
|
||||
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "toolResult", "system"]);
|
||||
expect(messages[1]?.text).toBe("hello");
|
||||
expect(messages.every((m) => m.streaming === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("message_update deltas append into a streaming bubble ended by matching message_end", () => {
|
||||
const id = "a9";
|
||||
const events = [
|
||||
ev({ type: "message_start", message: msg({ id, role: "assistant" }) }),
|
||||
ev({ type: "message_update", delta: "Hel" }),
|
||||
ev({ type: "message_update", delta: "lo" }),
|
||||
];
|
||||
let chat = deriveChat(events);
|
||||
expect(chat.messages).toHaveLength(1);
|
||||
expect(chat.messages[0]).toMatchObject({ role: "assistant", text: "Hello", streaming: true });
|
||||
expect(chat.busy).toBe(true);
|
||||
|
||||
chat = deriveChat([
|
||||
...events,
|
||||
ev({ type: "message_end", message: msg({ id, role: "assistant", text: "Hello world" }) }),
|
||||
]);
|
||||
expect(chat.messages).toHaveLength(1);
|
||||
expect(chat.messages[0]).toMatchObject({ text: "Hello world", streaming: false });
|
||||
expect(chat.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("non-assistant message_start does not open a stream", () => {
|
||||
const chat = deriveChat([ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) })]);
|
||||
expect(chat.messages).toHaveLength(0);
|
||||
expect(chat.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("missing delta does not append 'undefined'", () => {
|
||||
const chat = deriveChat([
|
||||
ev({ type: "message_start", message: msg({ id: "a", role: "assistant" }) }),
|
||||
ev({ type: "message_update" }),
|
||||
]);
|
||||
expect(chat.messages[0]?.text).toBe("");
|
||||
});
|
||||
|
||||
it("agent_start/agent_settled drive busy", () => {
|
||||
expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true);
|
||||
expect(deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })]).busy).toBe(false);
|
||||
});
|
||||
|
||||
it("message_start for a new assistant id drops the old stream", () => {
|
||||
const chat = deriveChat([
|
||||
ev({ type: "message_start", message: msg({ id: "a1", role: "assistant" }) }),
|
||||
ev({ type: "message_update", delta: "x" }),
|
||||
ev({ type: "message_start", message: msg({ id: "a2", role: "assistant" }) }),
|
||||
ev({ type: "message_update", delta: "y" }),
|
||||
]);
|
||||
expect(chat.messages).toHaveLength(1);
|
||||
expect(chat.messages[0]?.key).toBe("stream-a2");
|
||||
expect(chat.messages[0]?.text).toBe("y");
|
||||
});
|
||||
|
||||
it("message_end without message payload is ignored", () => {
|
||||
const chat = deriveChat([ev({ type: "message_end" })]);
|
||||
expect(chat.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tool lifecycle states", () => {
|
||||
const chat = deriveChat([
|
||||
toolStart("c1", "bash", "ls"),
|
||||
toolStart("c2", "read"),
|
||||
toolEnd("c1", true, "boom"),
|
||||
toolEnd("c2", false, "ok"),
|
||||
]);
|
||||
expect(chat.tools.get("c1")).toMatchObject({ name: "bash", args: "ls", running: false, isError: true, preview: "boom" });
|
||||
expect(chat.tools.get("c2")).toMatchObject({ running: false, isError: false, preview: "ok" });
|
||||
// defaults
|
||||
const chat2 = deriveChat([
|
||||
ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }),
|
||||
ev({ type: "tool_execution_end", toolCallId: "c3" }),
|
||||
]);
|
||||
expect(chat2.tools.get("c3")).toMatchObject({ running: false, isError: false, preview: "" });
|
||||
});
|
||||
|
||||
it("tool_execution_start without id and end for unknown id are ignored", () => {
|
||||
const chat = deriveChat([
|
||||
ev({ type: "tool_execution_start", toolName: "x" }),
|
||||
ev({ type: "tool_execution_end", toolCallId: "ghost" }),
|
||||
]);
|
||||
expect(chat.tools.size).toBe(0);
|
||||
});
|
||||
|
||||
it("tool execution defaults missing names/args", () => {
|
||||
const chat = deriveChat([ev({ type: "tool_execution_start", toolCallId: "c" })]);
|
||||
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- deriveTasks: todo derivation truth table ----------
|
||||
|
||||
function todoSnap(items: unknown): string {
|
||||
return JSON.stringify(items);
|
||||
}
|
||||
|
||||
describe("deriveTasks todos", () => {
|
||||
it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => {
|
||||
const events = [
|
||||
toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])),
|
||||
toolEnd("c1", false, todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }])),
|
||||
toolStart("c2", "todo", todoSnap([{ content: "b", status: "completed" }])),
|
||||
];
|
||||
const { todos } = deriveTasks(events);
|
||||
expect(todos).toEqual([
|
||||
{ content: "b", status: "completed", deleted: false },
|
||||
{ content: "a", status: "pending", deleted: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("status alias normalization table", () => {
|
||||
const aliases: Array<[unknown, string]> = [
|
||||
["in_progress", "in-progress"],
|
||||
["in-progress", "in-progress"],
|
||||
["inprogress", "in-progress"],
|
||||
["in progress", "in-progress"],
|
||||
["doing", "in-progress"],
|
||||
["started", "in-progress"],
|
||||
["completed", "completed"],
|
||||
["complete", "completed"],
|
||||
["done", "completed"],
|
||||
["weird", "pending"],
|
||||
[7, "pending"],
|
||||
];
|
||||
for (const [raw, expected] of aliases) {
|
||||
seq = 0;
|
||||
const events = [toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }]))];
|
||||
expect(deriveTasks(events).todos[0]?.status, `status ${String(raw)}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot sources: args of start, preview of end, toolResult message text", () => {
|
||||
const events = [
|
||||
toolStart("c1", "todo", todoSnap([{ content: "from-args" }])),
|
||||
toolEnd("c1", false, todoSnap([{ content: "from-preview" }])),
|
||||
ev({
|
||||
type: "message_end",
|
||||
message: msg({ id: "t1", role: "toolResult", text: todoSnap([{ content: "from-msg" }]), toolCallId: "c2" }),
|
||||
}),
|
||||
];
|
||||
// c2 has no tool_execution_start before its toolResult message; the second
|
||||
// pass only harvests toolResult text for known todo calls
|
||||
const eventsKnown = [
|
||||
...events,
|
||||
toolStart("c2", "todo"),
|
||||
];
|
||||
const { todos } = deriveTasks(eventsKnown);
|
||||
// latest snapshot (highest seq) is the toolResult message; earlier-only
|
||||
// items survive as deleted markers
|
||||
expect(todos.map((t) => t.content)).toEqual(["from-msg", "from-args", "from-preview"]);
|
||||
expect(todos.map((t) => t.deleted)).toEqual([false, true, true]);
|
||||
});
|
||||
|
||||
it("accepts wrapper objects and string arrays", () => {
|
||||
const wrapped = deriveTasks([
|
||||
toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })),
|
||||
]);
|
||||
expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]);
|
||||
const nestedItems = deriveTasks([toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] }))]);
|
||||
expect(nestedItems.todos).toEqual([{ content: "plain string", status: "pending", deleted: false }]);
|
||||
const nestedTasks = deriveTasks([toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] }))]);
|
||||
expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]);
|
||||
const nestedList = deriveTasks([toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] }))]);
|
||||
expect(nestedList.todos.map((t) => t.content)).toEqual(["tx"]);
|
||||
const subject = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ subject: "sj" }]))]);
|
||||
expect(subject.todos.map((t) => t.content)).toEqual(["sj"]);
|
||||
const summary = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }]))]);
|
||||
expect(summary.todos.map((t) => t.content)).toEqual(["sm"]);
|
||||
});
|
||||
|
||||
it("invalid snapshots are skipped: bad JSON, non-array, empty entries, empty content", () => {
|
||||
const cases: Array<string | undefined> = [
|
||||
"{bad json",
|
||||
JSON.stringify({ nope: 1 }),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify([{ content: "" }]),
|
||||
JSON.stringify([42]),
|
||||
"",
|
||||
undefined,
|
||||
];
|
||||
const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c));
|
||||
const { todos } = deriveTasks(events);
|
||||
expect(todos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no todo tool calls yields no todos", () => {
|
||||
expect(deriveTasks([]).todos).toHaveLength(0);
|
||||
expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- deriveTasks: subagents ----------
|
||||
|
||||
describe("deriveTasks subagents", () => {
|
||||
it("name fields fallback table", () => {
|
||||
const events = [
|
||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })),
|
||||
toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })),
|
||||
toolStart("c3", "subagent", JSON.stringify({ name: "planner" })),
|
||||
toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })),
|
||||
toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })),
|
||||
toolStart("c6", "subagent", JSON.stringify({ agentName: "" })),
|
||||
toolStart("c7", "subagent", "not json"),
|
||||
];
|
||||
const { subagents } = deriveTasks(events);
|
||||
expect(subagents.map((s) => s.name)).toEqual(["scout", "worker", "planner", "reviewer", "oracle", "subagent", "subagent"]);
|
||||
expect(subagents.every((s) => s.running && !s.isError)).toBe(true);
|
||||
});
|
||||
|
||||
it("tool_execution_end marks done / failed", () => {
|
||||
const events = [
|
||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||
toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })),
|
||||
toolEnd("c1", false),
|
||||
toolEnd("c2", true),
|
||||
];
|
||||
const { subagents } = deriveTasks(events);
|
||||
expect(subagents.find((s) => s.key === "c1")).toMatchObject({ running: false, isError: false });
|
||||
expect(subagents.find((s) => s.key === "c2")).toMatchObject({ running: false, isError: true });
|
||||
});
|
||||
|
||||
it("end for unknown subagent id ignored", () => {
|
||||
const { subagents } = deriveTasks([
|
||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||
toolEnd("ghost"),
|
||||
]);
|
||||
expect(subagents[0]?.running).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- deriveTasks: working tools ----------
|
||||
|
||||
describe("deriveTasks workingTools", () => {
|
||||
it("only running non-todo non-subagent tools are listed", () => {
|
||||
const events = [
|
||||
toolStart("c1", "bash", "ls"),
|
||||
toolStart("c2", "read", "f"),
|
||||
toolEnd("c1", false, "out"),
|
||||
toolStart("c3", "todo", todoSnap([{ content: "x" }])),
|
||||
toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })),
|
||||
];
|
||||
const { workingTools } = deriveTasks(events);
|
||||
expect(workingTools.map((w) => w.id)).toEqual(["c2"]);
|
||||
expect(workingTools[0]).toMatchObject({ name: "read", args: "f", running: true });
|
||||
});
|
||||
|
||||
it("tool_execution_update events do not affect derivation", () => {
|
||||
const { workingTools } = deriveTasks([
|
||||
toolStart("c1", "bash"),
|
||||
ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }),
|
||||
]);
|
||||
expect(workingTools).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveChat/deriveTasks edge branches", () => {
|
||||
it("unhandled event types fall through the switch", () => {
|
||||
const chat = deriveChat([
|
||||
ev({ type: "hello" }),
|
||||
ev({ type: "bye", reason: "shutdown" }),
|
||||
ev({ type: "session_info" }),
|
||||
ev({ type: "agent_end", usage: {} }),
|
||||
ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }),
|
||||
]);
|
||||
expect(chat.messages).toHaveLength(0);
|
||||
expect(chat.busy).toBe(false);
|
||||
expect(chat.tools.size).toBe(0);
|
||||
});
|
||||
|
||||
it("message_end keeps toolCalls and ends only the matching stream", () => {
|
||||
const withTools = deriveChat([
|
||||
ev({
|
||||
type: "message_end",
|
||||
message: msg({ id: "a1", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] }),
|
||||
}),
|
||||
]);
|
||||
expect(withTools.messages[0]?.toolCalls).toHaveLength(1);
|
||||
|
||||
// message_end for a different id does not close the open stream
|
||||
const mismatch = deriveChat([
|
||||
ev({ type: "message_start", message: msg({ id: "streaming" }) }),
|
||||
ev({ type: "message_update", delta: "par" }),
|
||||
ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }),
|
||||
]);
|
||||
expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/);
|
||||
expect(mismatch.messages[1]?.text).toBe("par");
|
||||
expect(mismatch.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("tool_execution_start without toolName maps to empty name in deriveTasks", () => {
|
||||
const { workingTools } = deriveTasks([ev({ type: "tool_execution_start", toolCallId: "c" })]);
|
||||
expect(workingTools).toHaveLength(1);
|
||||
expect(workingTools[0]?.name).toBe("");
|
||||
});
|
||||
|
||||
it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => {
|
||||
const { workingTools } = deriveTasks([
|
||||
toolStart("c1", "bash"),
|
||||
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
||||
]);
|
||||
expect(workingTools).toHaveLength(0);
|
||||
const chat = deriveChat([toolStart("c1", "bash"), ev({ type: "tool_execution_end", toolCallId: "c1" })]);
|
||||
expect(chat.tools.get("c1")).toMatchObject({ running: false, isError: false, preview: "" });
|
||||
});
|
||||
|
||||
it("snapshot from literal null args is ignored", () => {
|
||||
const { todos } = deriveTasks([toolStart("c1", "todo", "null")]);
|
||||
expect(todos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("content fallback: null content falls through to title", () => {
|
||||
const { todos } = deriveTasks([
|
||||
toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])),
|
||||
]);
|
||||
expect(todos.map((t) => t.content)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("optional-field null sides", () => {
|
||||
// tool_execution_end without toolCallId: deriveChat ternary else
|
||||
expect(() => deriveChat([ev({ type: "tool_execution_end" })])).not.toThrow();
|
||||
|
||||
// message without toolCalls: ?? [] fallback
|
||||
const bare = deriveChat([
|
||||
ev({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", id: "a", text: "x", thinking: null, toolCallId: null } as Message,
|
||||
}),
|
||||
]);
|
||||
expect(bare.messages[0]?.toolCalls).toEqual([]);
|
||||
|
||||
// subagent end without isError: ?? false
|
||||
const sa = deriveTasks([
|
||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
||||
]);
|
||||
expect(sa.subagents[0]).toMatchObject({ running: false, isError: false });
|
||||
|
||||
// toolResult message for a call id never seen: name lookup ?? ""
|
||||
const orphan = deriveTasks([
|
||||
ev({
|
||||
type: "message_end",
|
||||
message: msg({ id: "t", role: "toolResult", text: todoSnap([{ content: "z" }]), toolCallId: "ghost" }),
|
||||
}),
|
||||
]);
|
||||
expect(orphan.todos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
render: vi.fn(),
|
||||
createRoot: vi.fn(() => ({ render: mocks.render })),
|
||||
}));
|
||||
vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot }));
|
||||
vi.mock("./App", () => ({ default: (): null => null }));
|
||||
vi.mock("./index.css", () => ({}));
|
||||
|
||||
async function freshImport(): Promise<void> {
|
||||
vi.resetModules();
|
||||
await import("./main");
|
||||
}
|
||||
|
||||
function rootElement(): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.id = "root";
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("main", () => {
|
||||
it("renders the app into #root", async () => {
|
||||
const el = rootElement();
|
||||
await freshImport();
|
||||
expect(mocks.createRoot).toHaveBeenCalledWith(el);
|
||||
expect(mocks.render).toHaveBeenCalledTimes(1);
|
||||
const tree = mocks.render.mock.calls[0]?.[0] as { props: { children: ReactElement } };
|
||||
expect(tree.props.children).not.toBeNull();
|
||||
el.remove();
|
||||
mocks.createRoot.mockClear();
|
||||
mocks.render.mockClear();
|
||||
});
|
||||
|
||||
it("throws when #root is missing", async () => {
|
||||
await expect(freshImport()).rejects.toThrow("#root missing in index.html");
|
||||
expect(mocks.createRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventType, Route } from "./protocol";
|
||||
|
||||
describe("protocol", () => {
|
||||
it("PROTOCOL_VERSION is 1", async () => {
|
||||
const mod = await import("./protocol");
|
||||
expect(mod.PROTOCOL_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("EventType mirrors PROTOCOL.md", () => {
|
||||
expect(EventType.Hello).toBe("hello");
|
||||
expect(EventType.MessageStart).toBe("message_start");
|
||||
expect(EventType.MessageUpdate).toBe("message_update");
|
||||
expect(EventType.MessageEnd).toBe("message_end");
|
||||
expect(EventType.ToolExecutionStart).toBe("tool_execution_start");
|
||||
expect(EventType.ToolExecutionUpdate).toBe("tool_execution_update");
|
||||
expect(EventType.ToolExecutionEnd).toBe("tool_execution_end");
|
||||
expect(EventType.AgentStart).toBe("agent_start");
|
||||
expect(EventType.AgentEnd).toBe("agent_end");
|
||||
expect(EventType.AgentSettled).toBe("agent_settled");
|
||||
expect(EventType.SessionInfo).toBe("session_info");
|
||||
expect(EventType.Bye).toBe("bye");
|
||||
});
|
||||
|
||||
it("Route constants", () => {
|
||||
expect(Route.Sessions).toBe("/api/sessions");
|
||||
expect(Route.Spawn).toBe("/api/spawn");
|
||||
expect(Route.SpawnStatus).toBe("/api/spawn/status");
|
||||
expect(Route.GitlabStatus).toBe("/api/gitlab/status");
|
||||
expect(Route.GitlabConnect).toBe("/api/gitlab/connect");
|
||||
expect(Route.GitlabRepos).toBe("/api/gitlab/repos");
|
||||
});
|
||||
|
||||
it("session routes interpolate and encode ids", () => {
|
||||
expect(Route.SessionEvents("abc")).toBe("/api/sessions/abc/events");
|
||||
expect(Route.SessionPrompt("abc")).toBe("/api/sessions/abc/prompt");
|
||||
expect(Route.SessionAbort("abc")).toBe("/api/sessions/abc/abort");
|
||||
expect(Route.SessionContainer("abc")).toBe("/api/sessions/abc/container");
|
||||
expect(Route.SessionEvents("a/b c")).toBe("/api/sessions/a%2Fb%20c/events");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWsUrl,
|
||||
clearSettings,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
} from "./settings";
|
||||
|
||||
describe("settings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("getSettings returns null when absent", () => {
|
||||
expect(getSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it("saveSettings then getSettings round-trips", () => {
|
||||
saveSettings({ serverUrl: "http://x:8686", token: "t1" });
|
||||
expect(getSettings()).toEqual({ serverUrl: "http://x:8686", token: "t1" });
|
||||
});
|
||||
|
||||
it("clearSettings removes stored settings", () => {
|
||||
saveSettings({ serverUrl: "http://x", token: "t" });
|
||||
clearSettings();
|
||||
expect(getSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects corrupt JSON", () => {
|
||||
localStorage.setItem("lvmh.settings", "{not json");
|
||||
expect(getSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects wrong shapes", () => {
|
||||
localStorage.setItem(
|
||||
"lvmh.settings",
|
||||
JSON.stringify({ serverUrl: 1, token: "x" }),
|
||||
);
|
||||
expect(getSettings()).toBeNull();
|
||||
localStorage.setItem("lvmh.settings", JSON.stringify("nope"));
|
||||
expect(getSettings()).toBeNull();
|
||||
localStorage.setItem(
|
||||
"lvmh.settings",
|
||||
JSON.stringify({ serverUrl: "http://x" }),
|
||||
);
|
||||
expect(getSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it("buildWsUrl converts scheme and appends encoded token", () => {
|
||||
expect(buildWsUrl({ serverUrl: "http://a:1", token: "tok" })).toBe(
|
||||
"ws://a:1/ws?token=tok",
|
||||
);
|
||||
expect(buildWsUrl({ serverUrl: "https://a:1/", token: "tok" })).toBe(
|
||||
"wss://a:1/ws?token=tok",
|
||||
);
|
||||
expect(buildWsUrl({ serverUrl: "http://a:1//", token: "a b/c" })).toBe(
|
||||
"ws://a:1/ws?token=a%20b%2Fc",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSettings, saveSettings } from "./settings";
|
||||
import { classNames, relativeTime, useSessions, useToasts } from "./store";
|
||||
import { FakeWebSocket, mockFetchJson, seedSettings, stubReload } from "./test/setup";
|
||||
import type { EventFrame } from "./protocol";
|
||||
|
||||
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
|
||||
|
||||
describe("relativeTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats all buckets", () => {
|
||||
expect(relativeTime(null)).toBe("never");
|
||||
expect(relativeTime(NOW - 10_000)).toBe("just now");
|
||||
expect(relativeTime(NOW + 30_000)).toBe("just now");
|
||||
expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago");
|
||||
expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago");
|
||||
expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classNames", () => {
|
||||
it("joins truthy parts", () => {
|
||||
expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c");
|
||||
expect(classNames()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useToasts", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pushes a toast and removes it after TTL", () => {
|
||||
const { result } = renderHook(() => useToasts());
|
||||
act(() => result.current.push("hello"));
|
||||
expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]);
|
||||
|
||||
act(() => result.current.push("second"));
|
||||
expect(result.current.toasts).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4000);
|
||||
});
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
function listEvent(seq: number): EventFrame {
|
||||
return { v: 1, sessionId: "s1", seq, ts: 0, type: "message_end" };
|
||||
}
|
||||
|
||||
describe("useSessions", () => {
|
||||
it("returns null when unconfigured", () => {
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
|
||||
seedSettings();
|
||||
const sessions = [{ id: "s1", name: "one", online: true }];
|
||||
const fetchMock = mockFetchJson((url) => {
|
||||
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
|
||||
return [];
|
||||
});
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
|
||||
expect(result.current?.state).toBe("connecting");
|
||||
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
||||
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
expect(result.current?.state).toBe("open");
|
||||
|
||||
act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }] }));
|
||||
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]);
|
||||
|
||||
act(() => sock.serverMessage({ type: "spawn_status", jobs: [{ repo: "g/p", state: "cloning" }] }));
|
||||
expect(result.current?.spawnJobs).toEqual([{ repo: "g/p", state: "cloning" }]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current?.refresh();
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh failure pushes a toast", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down"));
|
||||
expect(result.current).not.toBeNull();
|
||||
});
|
||||
|
||||
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
|
||||
const seen: EventFrame[][] = [];
|
||||
let off: (() => void) | undefined;
|
||||
act(() => {
|
||||
off = result.current?.subscribe("s1", (events) => seen.push(events));
|
||||
});
|
||||
expect(sock.sent).toContain(JSON.stringify({ type: "subscribe", sessionId: "s1" }));
|
||||
|
||||
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 0, events: [listEvent(1)] }));
|
||||
act(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)] }));
|
||||
expect(seen).toEqual([[listEvent(1)]]);
|
||||
|
||||
act(() => off?.());
|
||||
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)] }));
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(sock.sent).toContain(JSON.stringify({ type: "unsubscribe", sessionId: "s1" }));
|
||||
});
|
||||
|
||||
it("auth failure clears settings and reloads", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const loc = stubReload();
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
act(() => sock.serverClose(1008));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
expect(getSettings()).toBeNull();
|
||||
await waitFor(() => expect(result.current).toBeNull());
|
||||
loc.restore();
|
||||
});
|
||||
|
||||
it("unmount closes the manager", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const push = vi.fn();
|
||||
const { unmount } = renderHook(() => useSessions(push));
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
const sock = FakeWebSocket.last();
|
||||
unmount();
|
||||
expect(sock.closeCode).toBe(4900);
|
||||
expect(sock.onclose).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings persistence used by the store", () => {
|
||||
it("saved settings are visible", () => {
|
||||
saveSettings({ serverUrl: "http://a", token: "t" });
|
||||
expect(getSettings()?.serverUrl).toBe("http://a");
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -95,8 +95,6 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore |
|
||||
};
|
||||
}, [pushToast]);
|
||||
|
||||
if (getSettings() === null) return null;
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
||||
@@ -120,5 +118,9 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore |
|
||||
[manager]
|
||||
);
|
||||
|
||||
// hooks above must all run before any early return: clearing settings
|
||||
// mid-flight (ws auth failure) must not change the hook order on re-render
|
||||
if (getSettings() === null) return null;
|
||||
|
||||
return { sessions, state, spawnJobs, refresh, subscribe };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
|
||||
// ---------- fake WebSocket ----------
|
||||
|
||||
export type WsHandler = ((ev: never) => void) | null;
|
||||
|
||||
export class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState: number = FakeWebSocket.CONNECTING;
|
||||
closeCode: number | null = null;
|
||||
sent: string[] = [];
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code = 1000): void {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.closeCode = code;
|
||||
}
|
||||
|
||||
// ---- test-side server simulation ----
|
||||
serverOpen(): void {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
|
||||
serverMessage(data: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
|
||||
}
|
||||
|
||||
serverClose(code = 1006): void {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.onclose?.({ code } as CloseEvent);
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
FakeWebSocket.instances = [];
|
||||
}
|
||||
|
||||
static last(): FakeWebSocket {
|
||||
const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
if (inst === undefined) throw new Error("no FakeWebSocket instance");
|
||||
return inst;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
window.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
|
||||
// ---------- fetch mocking helpers ----------
|
||||
|
||||
export function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: "StatusText",
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
type FetchHandler = (url: string, init?: RequestInit) => unknown;
|
||||
|
||||
export function mockFetchJson(handler: FetchHandler): MockInstance {
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const out = handler(url, init);
|
||||
return isResponseLike(out) ? out : jsonResponse(out);
|
||||
}) as typeof fetch);
|
||||
}
|
||||
|
||||
function isResponseLike(value: unknown): value is Response {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { json?: unknown }).json === "function" &&
|
||||
"ok" in (value as object)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- settings seed ----------
|
||||
|
||||
export function seedSettings(serverUrl = "http://srv", token = "tok"): void {
|
||||
localStorage.setItem("lvmh.settings", JSON.stringify({ serverUrl, token }));
|
||||
}
|
||||
|
||||
// ---------- window.location.reload stub ----------
|
||||
|
||||
export function stubReload(): { reload: ReturnType<typeof vi.fn>; restore: () => void } {
|
||||
const original = window.location;
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, "location", { value: { reload }, writable: true, configurable: true });
|
||||
return {
|
||||
reload,
|
||||
restore: (): void => {
|
||||
Object.defineProperty(window, "location", { value: original, writable: true, configurable: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Node's experimental localStorage getter (returns undefined without
|
||||
// --localstorage-file) shadows jsdom's under vitest; replace it with a plain
|
||||
// in-memory Storage so src modules see a working global.
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly map = new Map<string, string>();
|
||||
get length(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.map.keys())[index] ?? null;
|
||||
}
|
||||
getItem(key: string): string | null {
|
||||
return this.map.get(key) ?? null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.map.set(String(key), String(value));
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.map.delete(String(key));
|
||||
}
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const memoryStorage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, writable: true, value: memoryStorage });
|
||||
|
||||
// ---------- global hygiene ----------
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.reset();
|
||||
memoryStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWsManager } from "./ws";
|
||||
import { FakeWebSocket } from "./test/setup";
|
||||
|
||||
const URL = "ws://srv/ws?token=t";
|
||||
|
||||
describe("createWsManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("subscribe/unsubscribe/resubscribeAll are silent while the socket is not open", () => {
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last(); // still CONNECTING
|
||||
m.subscribe("s1");
|
||||
m.unsubscribe("s1");
|
||||
m.resubscribeAll();
|
||||
expect(sock.sent).toEqual([]);
|
||||
sock.serverClose();
|
||||
m.resubscribeAll();
|
||||
expect(sock.sent).toEqual([]);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("frames received after close() are ignored (handlers nulled)", () => {
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
m.close();
|
||||
expect(() =>
|
||||
sock.serverMessage({ type: "session_list", sessions: [] }),
|
||||
).not.toThrow();
|
||||
expect(FakeWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
it("onState receives transitions", () => {
|
||||
const states: string[] = [];
|
||||
const m = createWsManager(URL);
|
||||
const off = m.onState((s) => states.push(s));
|
||||
expect(states).toEqual(["connecting"]);
|
||||
expect(FakeWebSocket.last().url).toBe(URL);
|
||||
|
||||
FakeWebSocket.last().serverOpen();
|
||||
expect(states).toEqual(["connecting", "open"]);
|
||||
|
||||
off();
|
||||
FakeWebSocket.last().serverClose();
|
||||
expect(states).toEqual(["connecting", "open"]);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("accepts url as function", () => {
|
||||
const m = createWsManager(() => URL);
|
||||
expect(FakeWebSocket.last().url).toBe(URL);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("onFrame receives parsed frames; malformed frames dropped", () => {
|
||||
const m = createWsManager(URL);
|
||||
const frames: unknown[] = [];
|
||||
m.onFrame((f) => frames.push(f));
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
|
||||
sock.serverMessage({ type: "session_list", sessions: [] });
|
||||
expect(frames).toEqual([{ type: "session_list", sessions: [] }]);
|
||||
|
||||
sock.onmessage?.({ data: "{bad json" } as MessageEvent);
|
||||
sock.onmessage?.({ data: JSON.stringify(null) } as MessageEvent);
|
||||
sock.onmessage?.({
|
||||
data: JSON.stringify({ noType: true }),
|
||||
} as MessageEvent);
|
||||
sock.onmessage?.({ data: JSON.stringify(42) } as MessageEvent);
|
||||
expect(frames).toHaveLength(1);
|
||||
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("subscribe/unsubscribe send frames while open, resubscribe on reopen", () => {
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
|
||||
m.subscribe("s1");
|
||||
expect(sock.sent).toEqual([
|
||||
JSON.stringify({ type: "subscribe", sessionId: "s1" }),
|
||||
]);
|
||||
m.subscribe("s2");
|
||||
m.unsubscribe("s2");
|
||||
expect(sock.sent).toHaveLength(3);
|
||||
|
||||
sock.serverClose();
|
||||
// silent drop: reconnect timer scheduled with jitter
|
||||
vi.spyOn(Math, "random").mockReturnValue(1);
|
||||
vi.advanceTimersByTime(500);
|
||||
const sock2 = FakeWebSocket.last();
|
||||
sock2.serverOpen();
|
||||
expect(sock2.sent).toEqual([
|
||||
JSON.stringify({ type: "subscribe", sessionId: "s1" }),
|
||||
]);
|
||||
expect(m.subscriptions()).toEqual(new Set(["s1"]));
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("resubscribeAll replays live subscriptions over an open socket", () => {
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
m.subscribe("a");
|
||||
m.subscribe("b");
|
||||
sock.sent.length = 0;
|
||||
m.resubscribeAll();
|
||||
expect(sock.sent).toEqual([
|
||||
JSON.stringify({ type: "subscribe", sessionId: "a" }),
|
||||
JSON.stringify({ type: "subscribe", sessionId: "b" }),
|
||||
]);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("full backoff ladder reaches the 30s cap", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(1);
|
||||
const m = createWsManager(URL);
|
||||
const ladder = [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000];
|
||||
let elapsed = 0;
|
||||
for (const delay of ladder) {
|
||||
FakeWebSocket.last().serverClose();
|
||||
elapsed += delay;
|
||||
vi.advanceTimersByTime(delay);
|
||||
FakeWebSocket.last().serverOpen();
|
||||
}
|
||||
expect(FakeWebSocket.instances.length).toBe(ladder.length + 1);
|
||||
void elapsed;
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("successful open resets the backoff attempt counter", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(1);
|
||||
const m = createWsManager(URL);
|
||||
FakeWebSocket.last().serverClose();
|
||||
vi.advanceTimersByTime(500);
|
||||
FakeWebSocket.last().serverOpen();
|
||||
FakeWebSocket.last().serverClose();
|
||||
vi.advanceTimersByTime(500); // attempt reset to 0 by open
|
||||
expect(FakeWebSocket.instances.length).toBe(3);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("auth failure (close 1008) fires onAuthError and never reconnects", () => {
|
||||
const m = createWsManager(URL);
|
||||
let authErrors = 0;
|
||||
m.onAuthError(() => {
|
||||
authErrors += 1;
|
||||
});
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
sock.serverClose(1008);
|
||||
expect(authErrors).toBe(1);
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(FakeWebSocket.instances.length).toBe(1);
|
||||
expect(m.subscriptions() instanceof Set).toBe(true);
|
||||
m.close();
|
||||
});
|
||||
|
||||
it("close() tears the socket down and stops reconnection", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(1);
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
m.subscribe("s1");
|
||||
m.close();
|
||||
expect(sock.closeCode).toBe(4900);
|
||||
expect(sock.onclose).toBeNull();
|
||||
expect(sock.onmessage).toBeNull();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(FakeWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
it("callbacks unsubscribe via returned off functions", () => {
|
||||
const m = createWsManager(URL);
|
||||
const offFrame = m.onFrame(() => undefined);
|
||||
const offAuth = m.onAuthError(() => undefined);
|
||||
const offState = m.onState(() => undefined);
|
||||
offFrame();
|
||||
offAuth();
|
||||
offState();
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
sock.serverMessage({ type: "spawn_status", jobs: [] });
|
||||
sock.serverClose(1008);
|
||||
m.close();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string ws data does not throw", () => {
|
||||
const m = createWsManager(URL);
|
||||
const sock = FakeWebSocket.last();
|
||||
sock.serverOpen();
|
||||
expect(() =>
|
||||
sock.onmessage?.({ data: {} } as unknown as MessageEvent),
|
||||
).not.toThrow();
|
||||
m.close();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
@@ -14,4 +15,24 @@ export default defineConfig({
|
||||
"/ws": { target: DEV_PROXY_TARGET, ws: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
css: false,
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/**/*.test.*", "src/test/**"],
|
||||
reporter: ["text", "html"],
|
||||
thresholds: {
|
||||
lines: 95,
|
||||
branches: 95,
|
||||
functions: 95,
|
||||
statements: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user