fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+21 -3
View File
@@ -169,7 +169,27 @@ func (s *Server) handleSessionEvents(w http.ResponseWriter, r *http.Request) {
if limit > maxEventsLimit {
limit = maxEventsLimit
}
events, err := s.store.EventsAfter(id, after, limit)
var (
events []Event
err error
)
switch {
case r.URL.Query().Get("latest") != "":
if r.URL.Query().Get("latest") != "1" {
writeError(w, http.StatusBadRequest, "invalid latest")
return
}
events, err = s.store.EventsLatest(id, limit)
case r.URL.Query().Get("before") != "":
before, perr := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
if perr != nil || before < 0 {
writeError(w, http.StatusBadRequest, "invalid before")
return
}
events, err = s.store.EventsBefore(id, before, limit)
default:
events, err = s.store.EventsAfter(id, after, limit)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -360,7 +380,5 @@ func (s *Server) webHandler(webdist string) http.Handler {
})
}
var _ = daemonVersion
// daemonToken is set by main from LVMH_TOKEN (single process, read-only).
var daemonToken string
+47 -2
View File
@@ -4,6 +4,7 @@ package main
// serving, body/token validation edges.
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -37,7 +38,7 @@ func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -139,7 +140,7 @@ func TestAPISpawnFailures(t *testing.T) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -279,6 +280,50 @@ func TestAPIPromptBodyValidation(t *testing.T) {
}
}
func TestAPIEventsLatestAndBeforeParams(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
for seq := int64(1); seq <= 5; seq++ {
if err := store.AppendEvent(Event{SessionID: "s1", Seq: seq, TS: seq, Type: evAgentSettled, Payload: []byte(`{}`)}); err != nil {
t.Fatalf("append %d: %v", seq, err)
}
}
getSeqs := func(query string) []int64 {
t.Helper()
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+query, auth, "")
if code != http.StatusOK {
t.Fatalf("%s → %d %s, want 200", query, code, body)
}
var frames []map[string]any
if err := json.Unmarshal([]byte(body), &frames); err != nil {
t.Fatalf("decode %s: %v", query, err)
}
seqs := make([]int64, 0, len(frames))
for _, f := range frames {
seqs = append(seqs, int64(f["seq"].(float64)))
}
return seqs
}
if got := getSeqs("?latest=1&limit=2"); len(got) != 2 || got[0] != 4 || got[1] != 5 {
t.Fatalf("latest=1&limit=2 = %v, want [4 5] ascending", got)
}
if got := getSeqs("?before=4&limit=2"); len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("before=4&limit=2 = %v, want [2 3] ascending", got)
}
// default behavior unchanged
if got := getSeqs("?after=0&limit=10"); len(got) != 5 || got[0] != 1 {
t.Fatalf("after=0 = %v, want full ascending replay", got)
}
for _, q := range []string{"?before=abc", "?before=-1", "?latest=2"} {
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+q, auth, ""); code != http.StatusBadRequest {
t.Fatalf("%s → %d, want 400", q, code)
}
}
}
func TestAPIEventsEdgeCases(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
+111 -29
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
"os/exec"
@@ -61,6 +62,7 @@ const (
envLVMHRepo string = "LVMH_REPO"
stopTimeoutSeconds int = 10
buildContextReadLimit int64 = 64 << 20
maxSpawnJobs int = 50 // jobs map pruned to this many entries
)
var errNoContainer = errors.New("no container for session")
@@ -70,7 +72,7 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
// validRepoPath accepts "group/project" style paths (at least two segments).
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "-") }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "--") }
// newUUID returns a random RFC 4122 v4 UUID string.
func newUUID() string {
@@ -112,6 +114,7 @@ type SpawnResult struct {
// Spawner owns the docker client, per-repo clone serialization and job state.
type Spawner struct {
ctx context.Context // base ctx for async jobs (cancelled on shutdown)
store *Store
hub *Hub
cli *client.Client
@@ -125,16 +128,18 @@ type Spawner struct {
mu sync.Mutex
jobs map[string]*SpawnJob // keyed by sessionId
jobOrder []string // insertion order of jobs, for pruning
slugLocks map[string]*sync.Mutex
}
func NewSpawner(store *Store, hub *Hub, baseURL string) (*Spawner, error) {
func NewSpawner(ctx context.Context, store *Store, hub *Hub, baseURL string) (*Spawner, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("docker client: %w", err)
}
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
return &Spawner{
ctx: ctx,
store: store,
hub: hub,
cli: cli,
@@ -173,6 +178,13 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
if !ok {
j = &SpawnJob{SessionID: sessionID, Repo: repo}
s.jobs[sessionID] = j
s.jobOrder = append(s.jobOrder, sessionID)
if len(s.jobOrder) > maxSpawnJobs {
oldest := s.jobOrder[0]
copy(s.jobOrder, s.jobOrder[1:])
s.jobOrder = s.jobOrder[:len(s.jobOrder)-1]
delete(s.jobs, oldest)
}
}
j.Repo = repo
j.State = state
@@ -183,6 +195,23 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
s.hub.BroadcastSpawnStatus()
}
// deleteJob drops a session's job from the map and the insertion order.
func (s *Spawner) deleteJob(sessionID string) {
s.mu.Lock()
if _, ok := s.jobs[sessionID]; !ok {
s.mu.Unlock()
return
}
delete(s.jobs, sessionID)
for i, id := range s.jobOrder {
if id == sessionID {
s.jobOrder = append(s.jobOrder[:i], s.jobOrder[i+1:]...)
break
}
}
s.mu.Unlock()
}
// Start launches the async spawn pipeline and returns the new sessionId.
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
if _, err := s.imageExists(ctx); err != nil {
@@ -234,12 +263,12 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
return
}
s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(context.Background()); err != nil {
if err := s.ensureImage(s.ctx); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
s.setJob(sessionID, repo, stateCreating, "", "")
containerID, err := s.createAndStart(context.Background(), repo, slug, sessionID)
containerID, err := s.createAndStart(s.ctx, repo, slug, sessionID)
if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
@@ -252,6 +281,8 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
}
// cloneOrUpdate clones the repo into reposDir/<slug> or fast-forwards it.
// Credentials never appear in the clone URL (which git persists into
// .git/config); auth is passed per-invocation via http.extraHeader.
func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
dir := filepath.Join(s.reposDir, slug)
cloneURL, err := s.cloneURL(repo)
@@ -259,7 +290,16 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return err
}
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
if err := gitRun(dir, "pull", "--ff-only"); err != nil {
auth := s.gitAuthArgs()
if branch != "" && gitBranch(dir) != branch {
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
}
if err := gitRun(dir, "checkout", branch); err != nil {
return fmt.Errorf("git checkout %s: %w", repo, err)
}
}
if err := gitRun(dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
return fmt.Errorf("git pull %s: %w", repo, err)
}
return nil
@@ -267,7 +307,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
return err
}
args := []string{"clone"}
args := append(s.gitAuthArgs(), "clone")
if branch != "" {
args = append(args, "--branch", branch)
}
@@ -278,18 +318,34 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return nil
}
// cloneURL builds an authenticated https clone URL when a PAT is stored.
// cloneURL builds the clean https clone URL (never credential-bearing).
func (s *Spawner) cloneURL(repo string) (string, error) {
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
if err != nil {
return "", err
}
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil {
u.User = url.User(token)
}
return u.String(), nil
}
// gitAuthArgs returns per-invocation git args carrying the stored PAT via
// an HTTP header, or nil when no PAT is stored.
func (s *Spawner) gitAuthArgs() []string {
token, ok, _ := s.store.GetSetting(settingGitLabToken)
if !ok || token == "" {
return nil
}
return []string{"-c", "http.extraHeader=Authorization: token " + token}
}
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
func gitBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func gitRun(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
@@ -327,17 +383,29 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
if err != nil {
return err
}
var buf bytes.Buffer
if err := tarDir(&buf, s.buildContext); err != nil {
return fmt.Errorf("build context %s: %w", s.buildContext, err)
}
resp, err := s.cli.ImageBuild(ctx, &buf, build.ImageBuildOptions{
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, s.buildContext)
_ = pw.CloseWithError(err)
tarDone <- err
}()
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
Tags: []string{imageRefWorker},
Dockerfile: relDockerfile,
Remove: true,
})
if err != nil {
return fmt.Errorf("docker build: %w", err)
if buildErr != nil {
_ = pr.CloseWithError(buildErr) // unblock the tar goroutine
}
if tarErr := <-tarDone; tarErr != nil {
if resp.Body != nil {
_ = resp.Body.Close()
}
return fmt.Errorf("build context %s: %w", s.buildContext, tarErr)
}
if buildErr != nil {
return fmt.Errorf("docker build: %w", buildErr)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
@@ -378,6 +446,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
// drop the half-seeded volume so the next spawn retries fresh
// instead of silently booting into an empty workspace.
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
}
return "", fmt.Errorf("seed %s: %w", repoVolume, err)
}
}
@@ -409,6 +482,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
Binds: binds,
NetworkMode: container.NetworkMode(s.network),
AutoRemove: false,
Init: &[]bool{true}[0], // tini reaps bridge setup-hook zombies
}
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
@@ -445,18 +519,32 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
}
hostCfg := &container.HostConfig{
Binds: []string{repoVolume + ":" + workspaceMount},
Init: &[]bool{true}[0],
}
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
if err != nil {
return err
}
defer s.removeContainer(context.Background(), created.ID)
var buf bytes.Buffer
if err := tarDir(&buf, repoDir); err != nil {
return fmt.Errorf("tar clone: %w", err)
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, repoDir)
_ = pw.CloseWithError(err)
tarDone <- err
}()
copyErr := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, pr, container.CopyToContainerOptions{})
if copyErr != nil {
_ = pr.CloseWithError(copyErr) // unblock the tar goroutine
}
if err := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, &buf, container.CopyToContainerOptions{}); err != nil {
return fmt.Errorf("copy into volume: %w", err)
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if tarErr := <-tarDone; tarErr != nil {
return fmt.Errorf("tar clone: %w", tarErr)
}
if copyErr != nil {
return fmt.Errorf("copy into volume: %w", copyErr)
}
return nil
}
@@ -484,13 +572,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
if err := s.store.DeleteContainer(sessionID); err != nil {
return err
}
s.mu.Lock()
if j, ok := s.jobs[sessionID]; ok {
j.State = stateError
j.Message = "container removed"
j.UpdatedAt = time.Now().UnixMilli()
}
s.mu.Unlock()
s.deleteJob(sessionID)
s.hub.BroadcastSpawnStatus()
return nil
}
+18 -2
View File
@@ -4,6 +4,7 @@ package main
// fake git binary (PATH shim recording invocations).
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -38,6 +39,7 @@ type recordedCreate struct {
HostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
Init *bool `json:"Init"`
} `json:"HostConfig"`
}
@@ -58,6 +60,7 @@ type fakeDocker struct {
failStop bool
failWait bool
failVolumeCreate bool
failVolumeDelete bool
failArchive bool
archiveHang bool
waitHang bool
@@ -205,6 +208,16 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/volumes/"):
if f.failVolumeDelete {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume delete failed"}`)
return
}
name := strings.TrimPrefix(call.Path, "/volumes/")
f.mu.Lock()
delete(f.volume, name)
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && call.Path == "/containers/create":
var rc recordedCreate
if err := json.Unmarshal(body, &rc); err != nil {
@@ -290,8 +303,11 @@ func useFakeGit(t *testing.T, mode string) string {
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
" silent) exit 1;;\n" +
"esac\n" +
"# branch probe: emit the configured HEAD name\n" +
"if [ \"$1\" = '-C' ] && [ \"$3\" = rev-parse ]; then printf '%s' \"$FAKE_GIT_HEAD\"; exit 0; fi\n" +
"# clone: create a real worktree so seeding (tarDir) has files to copy\n" +
"if [ \"$1\" = clone ]; then d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\n' > \"$d/README.md\"; fi\n" +
"# (clone may be $1 or $3, depending on leading -c auth args)\n" +
"case \" $* \" in *\" clone \"*) d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\\n' > \"$d/README.md\";; 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)
@@ -345,7 +361,7 @@ func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example/")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
+10 -3
View File
@@ -18,6 +18,7 @@ import (
const (
settingGitLabToken string = "gitlab_pat"
projectsPerPage int = 50
maxRepoPages int = 5 // pagination cap: 5 pages / 250 repos
gitlabTimeout time.Duration = 15 * time.Second
)
@@ -133,18 +134,20 @@ func (g *GitLab) token() (string, error) {
return token, nil
}
// Repos lists member projects sorted by most recent activity.
// Repos lists member projects sorted by most recent activity, paging
// upstream until a short page (capped at maxRepoPages).
func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
token, err := g.token()
if err != nil {
return nil, err
}
var repos []GitLabRepo
for page := 1; page <= maxRepoPages; page++ {
path := fmt.Sprintf("/api/v1/user/repos?limit=%d&page=%d", projectsPerPage, page)
var projects []giteaRepo
path := fmt.Sprintf("/api/v1/user/repos?limit=%d", projectsPerPage)
if err := g.do(ctx, path, token, &projects); err != nil {
return nil, err
}
repos := make([]GitLabRepo, 0, len(projects))
for _, p := range projects {
if p.FullName == "" {
continue
@@ -158,6 +161,10 @@ func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
DefaultBranch: p.DefaultBranch,
})
}
if len(projects) < projectsPerPage {
break
}
}
sort.SliceStable(repos, func(i, j int) bool {
return repos[i].LastActivityAt > repos[j].LastActivityAt
})
+62
View File
@@ -4,8 +4,10 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
@@ -131,3 +133,63 @@ func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
t.Fatalf("repos = %+v, want empty (blank path skipped)", repos)
}
}
// newPagedGitLab serves full pages until pagesToFull+1, then a short page.
func newPagedGitLab(t *testing.T, alwaysFull bool) *GitLab {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
count := projectsPerPage
if !alwaysFull && page > 2 {
count = 1
}
var b strings.Builder
b.WriteString("[")
for i := 0; i < count; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"full_name":"team/p%d-%d","name":"P","owner":{"login":"team"},`+
`"updated_at":"2024-01-01T00:00:00Z","html_url":"https://gl","default_branch":"main"}`, page, i)
}
b.WriteString("]")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(b.String()))
})
up := httptest.NewServer(mux)
t.Cleanup(up.Close)
store := openTestStore(t)
if err := store.SetSetting(settingGitLabToken, "pat"); err != nil {
t.Fatalf("set token: %v", err)
}
return NewGitLab(store, up.URL)
}
func TestGitLabReposPaginatesUntilShortPage(t *testing.T) {
gl := newPagedGitLab(t, false) // 50 + 50 + 1 (short) → stops after page 3
repos, err := gl.Repos(context.Background())
if err != nil {
t.Fatalf("repos: %v", err)
}
if len(repos) != 2*projectsPerPage+1 {
t.Fatalf("repos = %d, want %d (pages 1-2 full + short page 3)", len(repos), 2*projectsPerPage+1)
}
if repos[0].Path != "team/p1-0" || repos[len(repos)-1].Path != "team/p3-0" {
t.Fatalf("first/last = %q..%q, want page1..page3", repos[0].Path, repos[len(repos)-1].Path)
}
}
func TestGitLabReposPaginationCapped(t *testing.T) {
gl := newPagedGitLab(t, true) // upstream always returns full pages
repos, err := gl.Repos(context.Background())
if err != nil {
t.Fatalf("repos: %v", err)
}
if len(repos) != maxRepoPages*projectsPerPage {
t.Fatalf("repos = %d, want capped at %d", len(repos), maxRepoPages*projectsPerPage)
}
}
+93 -23
View File
@@ -45,7 +45,10 @@ const (
webSendQueue int = 128
webFlushInterval time.Duration = 40 * time.Millisecond
webMaxPending int = 4096 // drop slow clients beyond this backlog
webMaxPendingByte int = 64 << 20 // ...or beyond this many queued bytes
promptSendTimeout time.Duration = 3 * time.Second
pongWait time.Duration = 60 * time.Second
pingPeriod time.Duration = 30 * time.Second
writeWait time.Duration = 5 * time.Second
daemonVersion int = 1 // envelope "v"
)
@@ -113,6 +116,7 @@ type webClient struct {
sub string // subscribed sessionId, "" when none
control [][]byte
events []pendingEvent
pendingBytes int
dropped bool
closeOnce sync.Once
@@ -126,18 +130,25 @@ func (c *webClient) drop() {
})
}
// overflows reports whether one more item of size n would exceed the caps.
func (c *webClient) overflows(n int) bool {
return len(c.control)+len(c.events) >= webMaxPending ||
c.pendingBytes+n > webMaxPendingByte
}
func (c *webClient) deliverControl(b []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if c.dropped {
return
}
if len(c.control)+len(c.events) >= webMaxPending {
if c.overflows(len(b)) {
c.dropped = true
go c.drop()
return
}
c.control = append(c.control, b)
c.pendingBytes += len(b)
}
func (c *webClient) deliverEvent(e pendingEvent) {
@@ -146,30 +157,68 @@ func (c *webClient) deliverEvent(e pendingEvent) {
if c.dropped {
return
}
if len(c.control)+len(c.events) >= webMaxPending {
if c.overflows(len(e.raw)) {
c.dropped = true
go c.drop()
return
}
c.events = append(c.events, e)
c.pendingBytes += len(e.raw)
}
// writeEventsFrame flushes one batched events frame for a single session.
func (c *webClient) writeEventsFrame(batch []pendingEvent) error {
items := make([]json.RawMessage, 0, len(batch))
for _, e := range batch {
items = append(items, e.raw)
}
payload, err := json.Marshal(map[string]any{
"type": frameEvents,
"sessionId": batch[0].sessionID,
"after": batch[0].seq - 1,
"events": items,
})
if err != nil {
return nil // unmarshalable raw JSON cannot happen; drop the batch
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
return c.conn.WriteMessage(websocket.TextMessage, payload)
}
// writePump flushes queued frames every webFlushInterval, batching events of
// the subscribed session into single frames. Never blocks the hub.
// the subscribed session into single frames (one frame per contiguous
// sessionID run — a mid-queue resubscribe must never mix sessions).
// Never blocks the hub.
func (c *webClient) writePump() {
ticker := time.NewTicker(webFlushInterval)
pings := time.NewTicker(pingPeriod)
defer ticker.Stop()
defer pings.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
case <-pings.C:
if !c.sendPing() {
c.hub.dropWeb(c)
return
}
continue
}
c.mu.Lock()
control := c.control
c.control = nil
events := c.events
c.events = nil
drained := 0
for _, b := range control {
drained += len(b)
}
for _, e := range events {
drained += len(e.raw)
}
c.pendingBytes -= drained
c.mu.Unlock()
for _, b := range control {
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
@@ -178,35 +227,34 @@ func (c *webClient) writePump() {
return
}
}
if len(events) == 0 {
continue
for i := 0; i < len(events); {
j := i
for j < len(events) && events[j].sessionID == events[i].sessionID {
j++
}
items := make([]json.RawMessage, 0, len(events))
after := events[0].seq - 1
for _, e := range events {
items = append(items, e.raw)
}
payload, err := json.Marshal(map[string]any{
"type": frameEvents,
"sessionId": events[0].sessionID,
"after": after,
"events": items,
})
if err != nil {
continue
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
if err := c.writeEventsFrame(events[i:j]); err != nil {
c.hub.dropWeb(c)
return
}
i = j
}
}
}
// sendPing writes one ping frame honoring the write deadline.
func (c *webClient) sendPing() bool {
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
return c.conn.WriteMessage(websocket.PingMessage, nil) == nil
}
// readPump consumes subscribe/unsubscribe frames; malformed input never kills the server.
func (c *webClient) readPump() {
defer c.hub.dropWeb(c)
c.conn.SetReadLimit(maxFrameSize)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
@@ -252,8 +300,11 @@ func (a *agentConn) drop() {
})
}
// writePump serializes daemon→plugin frames (prompt/abort/welcome).
// writePump serializes daemon→plugin frames (prompt/abort/welcome) and
// keeps the conn alive with periodic pings.
func (a *agentConn) writePump() {
pings := time.NewTicker(pingPeriod)
defer pings.Stop()
for {
select {
case <-a.done:
@@ -264,9 +315,20 @@ func (a *agentConn) writePump() {
a.drop()
return
}
case <-pings.C:
if !a.sendPing() {
a.drop()
return
}
}
}
}
// sendPing writes one ping frame honoring the write deadline.
func (a *agentConn) sendPing() bool {
_ = a.conn.SetWriteDeadline(time.Now().Add(writeWait))
return a.conn.WriteMessage(websocket.PingMessage, nil) == nil
}
// Hub tracks live agent conns and web subscribers; it is the only component
// that mutates online state.
@@ -418,6 +480,10 @@ func (h *Hub) ServeAgentWS(w http.ResponseWriter, r *http.Request) {
func (h *Hub) readAgentLoop(ac *agentConn) {
defer ac.drop()
ac.conn.SetReadLimit(maxFrameSize)
_ = ac.conn.SetReadDeadline(time.Now().Add(pongWait))
ac.conn.SetPongHandler(func(string) error {
return ac.conn.SetReadDeadline(time.Now().Add(pongWait))
})
registered := false
defer func() {
if registered {
@@ -543,11 +609,15 @@ func (h *Hub) handleEvent(f frame) {
func (h *Hub) unregister(ac *agentConn) {
h.mu.Lock()
removed := false
if cur, ok := h.agents[ac.sessionID]; ok && cur == ac {
delete(h.agents, ac.sessionID)
removed = true
}
h.mu.Unlock()
if ac.sessionID != "" {
// Only flip the persisted flag when no newer conn replaced this one;
// a reconnect races this unregister path.
if removed && ac.sessionID != "" {
if err := h.store.SetOnline(ac.sessionID, false); err != nil {
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
}
+223
View File
@@ -491,3 +491,226 @@ func TestWebClientDeliverToDropped(t *testing.T) {
t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending)
}
}
func TestWebClientByteOverflowDrops(t *testing.T) {
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
// stay under the frame-count cap; cross the byte cap on the last event.
meg := make([]byte, 1<<20)
var i int
for ; (i+1)*(1<<20) <= webMaxPendingByte; i++ {
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: meg})
}
if c.dropped {
t.Fatalf("client dropped at %dMiB, want only beyond %dMiB", i, webMaxPendingByte>>20)
}
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: meg}) // one over the cap
select {
case <-c.done:
case <-time.After(2 * time.Second):
t.Fatal("byte-overflowed web client was not dropped")
}
if !c.dropped {
t.Fatal("client must be marked dropped on byte overflow")
}
}
func TestUnregisterReplacedConnKeepsOnline(t *testing.T) {
store := openTestStore(t)
hub := NewHub(store)
if err := store.UpsertSession(SessionInfo{ID: "s1", Cwd: "/w", Model: "m", Provider: "p"}); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := store.SetOnline("s1", true); err != nil {
t.Fatalf("online: %v", err)
}
old := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
old.sessionID = "s1"
fresh := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
fresh.sessionID = "s1"
hub.agents["s1"] = old
hub.agents["s1"] = fresh // reconnect replaced old
hub.unregister(old)
rows, err := store.Sessions()
if err != nil || len(rows) != 1 {
t.Fatalf("sessions: %v %d", err, len(rows))
}
if !rows[0].OnlineDB {
t.Fatal("replaced conn unregistering must not flip the live session offline")
}
hub.unregister(fresh)
rows, err = store.Sessions()
if err != nil {
t.Fatalf("sessions: %v", err)
}
if rows[0].OnlineDB {
t.Fatal("last live conn unregistering must mark the session offline")
}
}
func TestPingPongConstants(t *testing.T) {
if pingPeriod >= pongWait {
t.Fatalf("pingPeriod %v must be < pongWait %v", pingPeriod, pongWait)
}
}
func TestWebClientSendPing(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{})}
if !c.sendPing() {
t.Fatal("sendPing over a live conn must succeed")
}
ac := &agentConn{hub: hub, conn: server, send: make(chan []byte, 1), done: make(chan struct{})}
if !ac.sendPing() {
t.Fatal("agent sendPing over a live conn must succeed")
}
}
func TestPongFramesKeepReadLoopsAlive(t *testing.T) {
// unsolicited pongs must pass through both read pumps (refreshing the
// read deadline) without killing the conn.
ts, _ := newTestServer(t)
ws := dialAgent(t, ts)
if err := ws.WriteMessage(websocket.PongMessage, nil); err != nil {
t.Fatalf("agent pong: %v", err)
}
_ = ws.WriteJSON(helloFrame("s1"))
if welcome := readFrame(t, ws); welcome["type"] != evWelcome {
t.Fatalf("agent read loop died after pong: %v", welcome)
}
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first web frame = %v", first)
}
if err := web.WriteMessage(websocket.PongMessage, nil); err != nil {
t.Fatalf("web pong: %v", err)
}
// subscribe processed by the (still live) read pump → live events flow.
if err := web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"}); err != nil {
t.Fatalf("subscribe: %v", err)
}
// A reader goroutine avoids deadline-based reads (gorilla conns fail
// permanently after a read timeout); resend until a batch arrives.
frames := make(chan map[string]any, 16)
go func() {
defer close(frames)
for {
var m map[string]any
if err := web.ReadJSON(&m); err != nil {
return
}
frames <- m
}
}()
start := time.Now()
for i := 0; ; i++ {
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s1", "seq": int64(i + 1), "ts": 10, "delta": "x"})
select {
case m, ok := <-frames:
if !ok {
t.Fatal("web conn closed; read loop died after pong")
}
if m["type"] == frameEvents {
return // read pump survived the pong and routed the subscription
}
case <-time.After(200 * time.Millisecond):
}
if time.Since(start) > 3*time.Second {
t.Fatal("no events frame delivered after pong")
}
}
}
func TestWebResubscribeSplitsEventBatches(t *testing.T) {
ts, _, hub := newTestServerHub(t)
agent1 := dialAgent(t, ts)
_ = agent1.WriteJSON(helloFrame("s1"))
_ = readFrame(t, agent1)
agent2 := dialAgent(t, ts)
_ = agent2.WriteJSON(helloFrame("s2"))
_ = readFrame(t, agent2)
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first web frame = %v", first)
}
webSubscribed := func(want string) func() bool {
return func() bool {
hub.mu.Lock()
defer hub.mu.Unlock()
for c := range hub.webs {
c.mu.Lock()
sub := c.sub
c.mu.Unlock()
if sub == want {
return true
}
}
return false
}
}
_ = web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"})
waitFor(t, 2*time.Second, webSubscribed("s1"))
_ = agent1.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s1", "seq": 1, "ts": 10, "delta": "one"})
readBatch := func(want string) map[string]any {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
_ = web.SetReadDeadline(time.Now().Add(2 * time.Second))
var m map[string]any
if err := web.ReadJSON(&m); err != nil {
t.Fatalf("read frame (want batch %s): %v", want, err)
}
if m["type"] != frameEvents {
continue
}
if m["sessionId"] != want {
t.Fatalf("events frame sessionId = %v, want %q (sessions must not mix)", m["sessionId"], want)
}
for _, e := range m["events"].([]any) {
if e.(map[string]any)["sessionId"] != want {
t.Fatalf("batch for %s contains event of %v", want, e.(map[string]any)["sessionId"])
}
}
return m
}
t.Fatalf("no events frame for %s before timeout", want)
return nil
}
readBatch("s1") // event A flushed while subscribed to A
_ = web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s2"})
waitFor(t, 2*time.Second, webSubscribed("s2"))
_ = agent2.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s2", "seq": 1, "ts": 11, "delta": "two"})
readBatch("s2") // event B must arrive as its own frame, never mixed with A's
}
+5 -1
View File
@@ -67,7 +67,9 @@ func run(args []string) error {
hub := NewHub(store)
gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase))
spawner, err := NewSpawner(store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
spawnerCtx, spawnerCtxCancel := context.WithCancel(context.Background())
defer spawnerCtxCancel()
spawner, err := NewSpawner(spawnerCtx, store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
if err != nil {
return fmt.Errorf("docker client: %w", err)
}
@@ -94,10 +96,12 @@ func run(args []string) error {
defer signal.Stop(stop)
select {
case err := <-serveErr:
spawnerCtxCancel()
return fmt.Errorf("listen: %w", err)
case <-stop:
}
log.Printf("shutting down")
spawnerCtxCancel()
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
+220 -20
View File
@@ -75,7 +75,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
t.Fatalf("missing env %v", wantEnv)
}
wantBinds := []string{
"lvmh-repo-group-project:" + workspaceMount,
"lvmh-repo-group--project:" + workspaceMount,
volumeSessions + ":" + sessionsMount,
volumePiCache + ":" + cacheMount,
}
@@ -90,6 +90,9 @@ func TestSpawnerStartHappyPath(t *testing.T) {
if c.HostConfig.NetworkMode != defaultNetwork {
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
}
if c.HostConfig.Init == nil || !*c.HostConfig.Init {
t.Fatalf("agent container Init = %v, want true (tini zombie reaping)", c.HostConfig.Init)
}
// fresh repo volume seeded from the clone via CopyToContainer (tar)
seed := f.createsByName("lvmh-seed-")
@@ -99,10 +102,13 @@ func TestSpawnerStartHappyPath(t *testing.T) {
if seed[0].Image != imageRefWorker {
t.Fatalf("seed create = %+v", seed[0])
}
wantSeedBinds := []string{"lvmh-repo-group-project:" + workspaceMount}
wantSeedBinds := []string{"lvmh-repo-group--project:" + workspaceMount}
if !reflect.DeepEqual(seed[0].HostConfig.Binds, wantSeedBinds) {
t.Fatalf("seed binds = %v, want %v (no host-path binds)", seed[0].HostConfig.Binds, wantSeedBinds)
}
if seed[0].HostConfig.Init == nil || !*seed[0].HostConfig.Init {
t.Fatalf("seed container Init = %v, want true", seed[0].HostConfig.Init)
}
if len(f.archives) != 1 || f.archives[0] == 0 {
t.Fatalf("CopyToContainer archives = %v, want one non-empty tar", f.archives)
}
@@ -150,7 +156,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
// 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/")
sp2, err := NewSpawner(context.Background(), sp.store, sp.hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -250,7 +256,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
sp, _ := newTestSpawner(t, f)
// existing clone → pull --ff-only in that dir, no clone
dir := filepath.Join(sp.reposDir, "group-project")
dir := filepath.Join(sp.reposDir, repoSlug("group/project"))
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
@@ -263,7 +269,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
}
}
func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
func TestSpawnerCloneURLNeverCarriesPAT(t *testing.T) {
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
@@ -273,12 +279,13 @@ func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
// A stored PAT must never leak into the persisted clone URL.
u, err := sp.cloneURL("group/project")
if err != nil {
t.Fatalf("cloneURL: %v", err)
}
if u != "https://pat-1@gitlab.example/group/project.git" {
t.Fatalf("cloneURL with PAT = %q", u)
if u != "https://gitlab.example/group/project.git" || strings.Contains(u, "pat-1") {
t.Fatalf("cloneURL with stored PAT = %q, want clean URL", u)
}
}
@@ -305,8 +312,8 @@ func TestSpawnerRemoveSession(t *testing.T) {
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)
if j.SessionID == "s1" {
t.Fatalf("job after removal = %+v, want entry deleted", j)
}
}
@@ -326,17 +333,17 @@ func TestSpawnerSeedVolumeCancel(t *testing.T) {
f := newFakeDocker()
f.archiveHang = true // server never answers the archive PUT
sp, _ := newTestSpawner(t, f)
if err := os.MkdirAll(filepath.Join(sp.reposDir, "group-project"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(sp.reposDir, repoSlug("group/project")), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sp.reposDir, "group-project", "README.md"), []byte("x"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(sp.reposDir, repoSlug("group/project"), "README.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
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") }()
go func() { done <- sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project") }()
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPut, "/archive") })
cancel()
select {
@@ -379,9 +386,13 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
}
func TestRepoSlugAndUUID(t *testing.T) {
if got := repoSlug("a/b/c"); got != "a-b-c" {
if got := repoSlug("a/b/c"); got != "a--b--c" {
t.Fatalf("repoSlug = %q", got)
}
// "a/b/c" and "a/b-c" must map to distinct slugs (no collision).
if repoSlug("a/b/c") == repoSlug("a/b-c") {
t.Fatalf("slug collision: %q", repoSlug("a/b/c"))
}
id := newUUID()
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
t.Fatalf("newUUID shape = %q", id)
@@ -391,6 +402,195 @@ func TestRepoSlugAndUUID(t *testing.T) {
}
}
func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
calls := readGitLog(t, gitLog)
if len(calls) != 1 {
t.Fatalf("git calls = %v", calls)
}
call := calls[0]
// auth travels as a per-invocation -c http.extraHeader arg…
if !strings.HasPrefix(call, "-c http.extraHeader=Authorization: token pat-1 clone ") {
t.Fatalf("git call = %q, want -c http.extraHeader auth before clone", call)
}
// …and the URL recorded into .git/config stays credential-free.
if !strings.Contains(call, " -- https://gitlab.example/group/project.git ") {
t.Fatalf("git call = %q, want clean clone URL", call)
}
if strings.Contains(call, "pat-1@") {
t.Fatalf("git call = %q leaks the PAT into the URL", call)
}
}
func TestSpawnerJobsPrunedToCap(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
for i := 0; i < maxSpawnJobs+10; i++ {
sp.setJob(fmt.Sprintf("s%d", i), "group/project", stateCloning, "", "")
}
jobs := sp.JobsSnapshot()
if len(jobs) != maxSpawnJobs {
t.Fatalf("jobs = %d, want capped at %d", len(jobs), maxSpawnJobs)
}
seen := map[string]bool{}
for _, j := range jobs {
seen[j.SessionID] = true
}
if seen["s0"] {
t.Fatal("oldest job must be pruned first")
}
for _, id := range []string{fmt.Sprintf("s%d", maxSpawnJobs), fmt.Sprintf("s%d", maxSpawnJobs+9)} {
if !seen[id] {
t.Fatalf("newest job %s pruned; kept = %v", id, seen)
}
}
}
func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.failArchive = true // CopyToContainer fails → seed fails
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, "seed") {
t.Fatalf("job message = %q, want seed failure", job.Message)
}
if !f.hasCall(http.MethodDelete, "/volumes/lvmh-repo-group--project") {
t.Fatal("failed seed must force-remove the repo volume for a fresh retry")
}
if f.volumeExists("lvmh-repo-group--project") {
t.Fatal("repo volume must not linger half-seeded")
}
}
func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.failArchive = true
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
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, "seed") {
t.Fatalf("job message = %q, want seed failure despite cleanup failure", job.Message)
}
}
func TestSpawnerCloneOrUpdateBranchFetchFails(t *testing.T) {
useFakeGit(t, fakeGitModeFail) // branch probe and fetch both fail
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
slug := repoSlug("group/project")
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
err := sp.cloneOrUpdate("group/project", "dev", slug)
if err == nil || !strings.Contains(err.Error(), "git fetch") {
t.Fatalf("cloneOrUpdate branch fetch failure = %v, want git fetch error", err)
}
}
func TestSpawnerDeleteJobMissing(t *testing.T) {
sp, _ := newTestSpawner(t, newFakeDocker())
sp.deleteJob("never-existed") // no-op, must not panic
sp.setJob("s1", "group/project", stateRunning, "cid", "")
sp.deleteJob("s1")
for _, j := range sp.JobsSnapshot() {
if j.SessionID == "s1" {
t.Fatalf("job %s still present after deleteJob", j.SessionID)
}
}
}
func TestSpawnerCloneOrUpdateSwitchesBranch(t *testing.T) {
cases := []struct {
name string
branch string
headOut string
wantTail []string
}{
{
name: "same-branch-skips-fetch-checkout",
branch: "main",
headOut: "main\n",
wantTail: []string{"pull --ff-only"},
},
{
name: "different-branch-fetches-and-checks-out",
branch: "dev",
headOut: "main\n",
wantTail: []string{"fetch origin dev", "checkout dev", "pull --ff-only"},
},
{
name: "unknown-head-falls-through-to-fetch",
branch: "dev",
headOut: "",
wantTail: []string{"fetch origin dev", "checkout dev", "pull --ff-only"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
t.Setenv("FAKE_GIT_HEAD", tc.headOut)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
slug := repoSlug("group/project")
dir := filepath.Join(sp.reposDir, slug)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := sp.cloneOrUpdate("group/project", tc.branch, slug); err != nil {
t.Fatalf("cloneOrUpdate: %v", err)
}
calls := readGitLog(t, gitLog)
if len(calls) != len(tc.wantTail)+1 { // +1: rev-parse probe
t.Fatalf("git calls = %v, want %v (+rev-parse)", calls, tc.wantTail)
}
if !strings.HasPrefix(calls[0], "-C ") || !strings.HasSuffix(calls[0], "rev-parse --abbrev-ref HEAD") {
t.Fatalf("first call = %q, want branch probe", calls[0])
}
authPrefix := "-c http.extraHeader=Authorization: token pat-1 "
for i, want := range tc.wantTail {
got := calls[i+1]
if want == "checkout dev" {
if got != want { // checkout needs no auth
t.Fatalf("call %d = %q, want %q", i+1, got, want)
}
continue
}
if got != authPrefix+want {
t.Fatalf("call %d = %q, want %q%q", i+1, got, authPrefix, want)
}
}
})
}
}
func TestEnvOr(t *testing.T) {
t.Setenv("LVMH_TEST_ENV_OR", " value ")
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "value" {
@@ -484,7 +684,7 @@ func TestGitRunSilentFailure(t *testing.T) {
func TestSpawnerNewBadDockerHost(t *testing.T) {
t.Setenv("DOCKER_HOST", "http://")
store := openTestStore(t)
if _, err := NewSpawner(store, NewHub(store), "https://gitlab.example"); err == nil {
if _, err := NewSpawner(context.Background(), store, NewHub(store), "https://gitlab.example"); err == nil {
t.Fatal("NewSpawner must fail on an unparseable DOCKER_HOST")
}
}
@@ -533,7 +733,7 @@ func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
t.Fatalf("write file: %v", err)
}
sp.reposDir = file
if err := sp.cloneOrUpdate("group/project", "", "group-project"); err == nil {
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err == nil {
t.Fatal("cloneOrUpdate with file reposDir must fail")
}
}
@@ -570,7 +770,7 @@ func TestSpawnerEnsureImageErrors(t *testing.T) {
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true // repo volume exists → skip seed
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", "")
@@ -590,12 +790,12 @@ func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
ctx := context.Background()
f.failCreate = true
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
if err := sp.seedVolume(ctx, repoSlug("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 {
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
t.Fatal("seedVolume with failing start must fail")
}
}
@@ -632,7 +832,7 @@ func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
useFakeGit(t, fakeGitModeFail)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
dir := filepath.Join(sp.reposDir, "group-project")
dir := filepath.Join(sp.reposDir, repoSlug("group/project"))
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
@@ -647,7 +847,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
// worker container create/start instead of at the seed container.
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true
f.volume["lvmh-repo-group--project"] = true
sp, _ := newTestSpawner(t, f)
f.failCreate = true
+42 -1
View File
@@ -131,6 +131,47 @@ func (s *Store) EventsAfter(sessionID string, after int64, limit int) ([]Event,
return out, rows.Err()
}
// EventsLatest returns the newest limit events for the session, ascending.
func (s *Store) EventsLatest(sessionID string, limit int) ([]Event, error) {
return s.eventsDesc(sessionID, ` WHERE sessionId=? ORDER BY seq DESC LIMIT ?`, limit, sessionID)
}
// EventsBefore returns the newest window of at most limit events with
// seq < before, ascending.
func (s *Store) EventsBefore(sessionID string, before int64, limit int) ([]Event, error) {
return s.eventsDesc(sessionID, ` WHERE sessionId=? AND seq<? ORDER BY seq DESC LIMIT ?`,
limit, sessionID, before)
}
// eventsDesc runs a newest-first query and returns the rows reversed
// (oldest-first), for newest-window reads.
func (s *Store) eventsDesc(sessionID, where string, limit int, args ...any) ([]Event, error) {
rows, err := s.db.Query(
`SELECT sessionId, seq, ts, type, payload FROM events`+where, append(args, limit)...)
if err != nil {
return nil, err
}
defer rows.Close()
var desc []Event
for rows.Next() {
var e Event
var payload string
if err := rows.Scan(&e.SessionID, &e.Seq, &e.TS, &e.Type, &payload); err != nil {
return nil, err
}
e.Payload = json.RawMessage(payload)
desc = append(desc, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Event, len(desc))
for i, e := range desc {
out[len(desc)-1-i] = e
}
return out, nil
}
// UpsertSession inserts or refreshes the info snapshot for a session.
func (s *Store) UpsertSession(info SessionInfo) error {
blob, err := json.Marshal(info)
@@ -158,7 +199,7 @@ func (s *Store) SetOnline(sessionID string, online bool) error {
return err
}
// Sessions returns every known session row ordered oldest-first.
// Sessions returns every known session row ordered by session id.
func (s *Store) Sessions() ([]SessionRow, error) {
rows, err := s.db.Query(`SELECT id, info, lastSeq, lastEventAt, online FROM sessions ORDER BY id`)
if err != nil {
+54
View File
@@ -85,6 +85,9 @@ func TestStoreCorruptRows(t *testing.T) {
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
t.Fatal("EventsAfter with text seq must scan-error")
}
if _, err := store.EventsLatest("s1", 10); err == nil {
t.Fatal("EventsLatest with text seq must scan-error")
}
}
func TestStoreAppendEventDefaultPayload(t *testing.T) {
@@ -100,3 +103,54 @@ func TestStoreAppendEventDefaultPayload(t *testing.T) {
t.Fatalf("payload = %q, want {}", events[0].Payload)
}
}
func seedEvents(t *testing.T, store *Store, sessionID string, seqs ...int64) {
t.Helper()
for _, seq := range seqs {
if err := store.AppendEvent(Event{SessionID: sessionID, Seq: seq, TS: seq, Type: evAgentSettled, Payload: []byte(`{}`)}); err != nil {
t.Fatalf("append %d: %v", seq, err)
}
}
}
func TestStoreEventsLatestAndBefore(t *testing.T) {
store := openTestStore(t)
seedEvents(t, store, "s1", 1, 2, 3, 4, 5)
latest, err := store.EventsLatest("s1", 3)
if err != nil {
t.Fatalf("EventsLatest: %v", err)
}
if len(latest) != 3 {
t.Fatalf("latest = %d events, want 3", len(latest))
}
for i, want := range []int64{3, 4, 5} {
if latest[i].Seq != want {
t.Fatalf("latest[%d].Seq = %d, want %d (newest window, ascending)", i, latest[i].Seq, want)
}
}
before, err := store.EventsBefore("s1", 4, 2)
if err != nil {
t.Fatalf("EventsBefore: %v", err)
}
if len(before) != 2 {
t.Fatalf("before = %d events, want 2", len(before))
}
for i, want := range []int64{2, 3} {
if before[i].Seq != want {
t.Fatalf("before[%d].Seq = %d, want %d (newest below 4, ascending)", i, before[i].Seq, want)
}
}
// windows past the boundaries degrade cleanly
if all, _ := store.EventsLatest("s1", 100); len(all) != 5 {
t.Fatalf("latest over count = %d, want 5", len(all))
}
if none, _ := store.EventsBefore("s1", 1, 10); len(none) != 0 {
t.Fatalf("before 1 = %d events, want 0", len(none))
}
if missing, _ := store.EventsLatest("nope", 10); len(missing) != 0 {
t.Fatalf("latest unknown session = %d events, want 0", len(missing))
}
}
+2 -3
View File
@@ -492,9 +492,8 @@ export default function (pi: ExtensionAPI): void {
// notice reflects every gap, not just cap drops.
const covered: number =
replayBuf.filter((f) => f.seq <= lastSeq).length +
sendQueue.filter(
(f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq,
).length;
sendQueue.filter((f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq)
.length;
if (covered > 0) {
droppedEvents += covered;
log(
+6 -3
View File
@@ -18,7 +18,10 @@ interface BoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
export class ErrorBoundary extends Component<
{ children: ReactNode },
BoundaryState
> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
@@ -102,6 +105,7 @@ export default function App() {
const archived = store.sessions.filter((s) => !s.online).sort(byActivity);
return (
<ErrorBoundary>
<div className="app-shell">
<nav
className={classNames("sidebar", sidebarOpen && "open")}
@@ -180,7 +184,6 @@ export default function App() {
>
</button>
<ErrorBoundary>
<Routes>
<Route
path="/"
@@ -202,7 +205,6 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ErrorBoundary>
</main>
<div className="toasts" role="status">
{toasts.map((t) => (
@@ -212,5 +214,6 @@ export default function App() {
))}
</div>
</div>
</ErrorBoundary>
);
}
+451 -304
View File
@@ -1,336 +1,483 @@
import { fireEvent, render, screen, waitFor } 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";
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, SessionListItem } from "./protocol";
import { Route as ApiRoute } from "./protocol";
import { fetchJson } from "./api";
import SpawnView from "./SpawnView";
import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
function msg(partial: Partial<ChatMessage>): ChatMessage {
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 {
key: `k-${Math.random()}`,
role: "assistant",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
...partial,
sessions: [],
state: "open",
spawnJobs: [],
refresh: async (): Promise<SessionListItem[]> =>
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
subscribe: (): (() => void) => () => undefined,
...over,
};
}
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()}
/>,
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>
);
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there");
}
/** 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();
});
it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render(
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
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("shows loading repos while the list is pending", async () => {
const gate = { resolve: null as ((v: unknown) => void) | null };
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return new Promise((res) => {
gate.resolve = res;
});
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("loading repos…")).toBeInTheDocument();
gate.resolve?.([repo("g/late")]);
await flush();
expect(await screen.findByText("g/late")).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]> = [];
let sessionsOnline = false; // flipped after the first poll tick
const fetchMock = 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/sessions"))
return sessionsOnline
? [
{
id: "new-1",
name: "spawned",
cwd: "/w",
model: "m",
provider: "p",
agent: true,
repo: "g/proj",
startedAt: 1,
online: true,
lastEventAt: 1,
},
]
: [];
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(container.querySelector(".bubble")?.children).toHaveLength(0);
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();
// each poll tick issues exactly one sessions fetch (refresh reuse, S7)
const sessionsFetches = fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions"),
).length;
expect(sessionsFetches).toBe(1);
// session comes online -> next tick navigates
sessionsOnline = true;
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
unmount();
});
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("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" };
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 [];
});
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",
}),
const store = makeStore({
spawnJobs: [
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
],
]);
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");
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
});
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("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: (): Promise<SessionListItem[]> =>
Promise.reject(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("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("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("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");
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();
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
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("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();
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();
});
});
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("SpawnSteps", () => {
it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const steps = document.querySelectorAll(".spawn-progress .step");
expect(steps).toHaveLength(4);
expect(steps[0]?.className).toContain("done");
expect(steps[1]?.className).toContain("current");
expect(steps[2]?.className).toBe("step");
});
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("progress element carries progressbar semantics for the current step", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp2", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
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,
const store = makeStore({
spawnJobs: [
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
],
});
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);
});
});
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
render(
<Bubble
msg={{ key: "k", role: "assistant", text: "copy me", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
const btn = screen.getByRole("button", { name: "Copy message" });
fireEvent.click(btn);
expect(writeText).toHaveBeenCalledWith("copy me");
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
});
it("user bubbles get a copy button, toolResults do not", () => {
const { rerender } = render(
<Bubble
msg={{ key: "u", role: "user", text: "hi", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
tools={new Map()}
/>,
);
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
rerender(
<Bubble
msg={{ key: "t", role: "toolResult", text: "r", thinking: null, toolCalls: [], toolCallId: "c1", streaming: false }}
tools={new Map()}
/>,
);
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const bar = screen.getByRole("progressbar");
expect(bar).toHaveAttribute("aria-valuemin", "1");
expect(bar).toHaveAttribute("aria-valuemax", "4");
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
});
});
+25 -1
View File
@@ -136,9 +136,20 @@ interface Props {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
/** an older page exists beyond the loaded window (B1) */
hasOlder: boolean;
loadingOlder: boolean;
onLoadOlder: () => void;
}
export default function ChatStream({ messages, tools, busy }: Props) {
export default function ChatStream({
messages,
tools,
busy,
hasOlder,
loadingOlder,
onLoadOlder,
}: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
@@ -166,6 +177,19 @@ export default function ChatStream({ messages, tools, busy }: Props) {
return (
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{hasOlder && (
<div className="load-older">
<button
type="button"
className="btn-secondary"
aria-label="Load older messages"
disabled={loadingOlder}
onClick={onLoadOlder}
>
{loadingOlder ? "loading…" : "Load older"}
</button>
</div>
)}
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
+336 -6
View File
@@ -7,7 +7,7 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventFrame } from "./protocol";
import { ApiError } from "./api";
@@ -42,7 +42,7 @@ function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
sessions,
state: "open",
spawnJobs: [],
refresh: async () => undefined,
refresh: async () => [],
subscribe: (
sessionId: string,
onEvents: (events: EventFrame[]) => void,
@@ -131,6 +131,33 @@ function historyEvents(): EventFrame[] {
];
}
/** seq from..to inclusive, one user message per seq. */
function pageEvents(
from: number,
to: number,
text: (n: number) => string,
): EventFrame[] {
const out: EventFrame[] = [];
for (let n = from; n <= to; n += 1) {
out.push({
v: 1,
sessionId: "s1",
seq: n,
ts: 0,
type: "message_end",
message: {
role: "user",
id: `u${n}`,
text: text(n),
thinking: null,
toolCalls: [],
toolCallId: null,
},
});
}
return out;
}
beforeEach(() => {
seq = 0;
currentSub = null;
@@ -362,7 +389,8 @@ describe("ChatView", () => {
</MemoryRouter>,
);
await screen.findByText("caught up");
expect(after).toBe("0");
// initial load is latest=1: no after= cursor has been issued yet
expect(after).toBe("");
// reconnect: state closed -> open triggers the after=N refetch
act(() => {
@@ -418,10 +446,174 @@ describe("ChatView", () => {
});
});
describe("ChatView history pagination (B1)", () => {
it("initial history fetch requests the newest page via latest=1", async () => {
const eventsUrls: string[] = [];
mockFetchJson((url) => {
if (url.includes("/events")) {
eventsUrls.push(url);
return historyEvents();
}
return [];
});
renderChat(makeStore());
await screen.findByText("hello there");
await waitFor(() => expect(eventsUrls.length).toBeGreaterThan(0));
expect(eventsUrls[0]).toContain("latest=1");
expect(eventsUrls[0]).toContain("limit=1000");
expect(eventsUrls[0]).not.toContain("after=");
});
it("short first page renders no Load older button", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
renderChat(makeStore());
await screen.findByText("hello there");
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull();
});
it("Load older prepends the previous page and hides at a short page", async () => {
const olderUrls: string[] = [];
mockFetchJson((url) => {
if (url.includes("/events")) {
if (url.includes("before=")) {
olderUrls.push(url);
return pageEvents(1, 2, (n) => `oldest-${n}`);
}
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await screen.findByText("oldest-1");
expect(screen.getByText("m1002")).toBeInTheDocument();
expect(olderUrls).toHaveLength(1);
expect(olderUrls[0]).toContain("before=3");
expect(olderUrls[0]).toContain("limit=1000");
// short page (2 < 1000): the button disappears
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull(),
);
});
it("cursor refetch after loading older pages still uses the max seq", async () => {
let after = "";
mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? "";
if (url.includes("/events")) {
if (url.includes("before="))
return pageEvents(1, 2, (n) => `oldest-${n}`);
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
const { rerender } = renderChat(makeStore());
await screen.findByText("m1002");
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await screen.findByText("oldest-1");
// reconnect: cursor must still point at the newest seq (1002), not at the
// older batch's max (2) — older pages must not poison lastSeqRef
rerenderChatAgain(rerender, makeStore({ state: "connecting" }));
rerenderChatAgain(rerender, makeStore({ state: "open" }));
await vi.waitFor(() => expect(after).toBe("1002"));
});
it("Load older failure toasts and keeps the button", async () => {
mockFetchJson((url) => {
if (url.includes("before="))
return jsonResponse({ error: "older fail" }, 500);
if (url.includes("/events")) return pageEvents(3, 1002, (n) => `m${n}`);
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
await userEvent.click(
screen.getByRole("button", { name: "Load older messages" }),
);
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("older fail"),
);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeEnabled();
});
it("a second click while a page load is in flight is ignored", async () => {
let releaseOlder: ((v: EventFrame[]) => void) | null = null;
let beforeCalls = 0;
mockFetchJson((url) => {
if (url.includes("/events")) {
if (url.includes("before=")) {
beforeCalls += 1;
return new Promise<EventFrame[]>((res) => {
releaseOlder = res;
});
}
return pageEvents(3, 1002, (n) => `m${n}`);
}
return [];
});
renderChat(makeStore());
await screen.findByText("m1002");
const btn = screen.getByRole("button", { name: "Load older messages" });
await userEvent.click(btn);
// still pending: the button is disabled and re-entry is a no-op
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(beforeCalls).toBe(1);
await act(async () => {
releaseOlder?.(pageEvents(1, 2, (n) => `oldest-${n}`));
});
await screen.findByText("oldest-1");
expect(beforeCalls).toBe(1);
});
});
describe("ChatView busy vs offline (B2)", () => {
it("offline session with history ending at agent_start shows no typing and the send button", async () => {
mockFetchJson((url) =>
url.includes("/events") ? [ev("agent_start")] : [],
);
const offline = [{ ...sessions[0]!, online: false }];
const { container } = renderChat(makeStore({ sessions: offline }));
await waitFor(() => expect(currentSub).not.toBeNull());
await vi.waitFor(() =>
expect(container.querySelector(".typing")).toBeNull(),
);
expect(screen.queryByLabelText("Abort current run")).toBeNull();
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
});
it("online session with history ending at agent_start still shows the stop button", async () => {
mockFetchJson((url) =>
url.includes("/events") ? [ev("agent_start")] : [],
);
renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
expect(screen.queryByLabelText("Send message")).toBeNull();
});
});
describe("ChatView close pi", () => {
it("close button deletes the container, toasts and refreshes (agent session)", async () => {
const agent = [{ ...sessions[0]!, id: "s1", agent: true }];
const refresh = vi.fn(async () => undefined);
const refresh = vi.fn(async () => []);
const deletes: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "DELETE" && url.includes("/container")) {
@@ -485,6 +677,141 @@ describe("ChatView refetch failure", () => {
});
});
describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
// same-tree navigation: ChatView stays mounted while the session id changes
function Switcher({
state,
}: {
state: "connecting" | "open";
}): React.ReactElement {
const nav = useNavigate();
return (
<>
<button
type="button"
aria-label="switch session"
onClick={() => nav("/s/ghost")}
>
switch
</button>
<Routes>
<Route
path="/s/:id"
element={
<ChatView store={makeStore({ state })} pushToast={pushToast} />
}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</>
);
}
it("refetch resolving after a session switch does not merge stale events (S1)", async () => {
let releaseRefetch: ((v: EventFrame[]) => void) | null = null;
const staleFrame: EventFrame = {
v: 1,
sessionId: "s1",
seq: 77,
ts: 0,
type: "message_end",
message: {
role: "user",
id: "u77",
text: "stale s1 frame",
thinking: null,
toolCalls: [],
toolCallId: null,
},
};
let ghostAfter = "unfetched";
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/ghost/events")) {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) ghostAfter = m[1] ?? "";
return [];
}
if (url.startsWith("http://srv/api/sessions/s1/events")) {
if (url.includes("after="))
return new Promise<EventFrame[]>((res) => {
releaseRefetch = res;
});
return [];
}
return [];
});
const { rerender } = render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="connecting" />
</MemoryRouter>,
);
// let the initial history load settle (loadedRef) before the ws opens
await act(async () => {
await Promise.resolve();
});
// ws (re)opens so the after=N refetch fires for s1
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await waitFor(() => expect(releaseRefetch).not.toBeNull());
// navigate away to ghost while the s1 refetch is in flight
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
// ghost reconnects: its own cursor refetch must still start at 0 — the
// stale s1 batch resolving concurrently must not poison it
rerender(
<MemoryRouter initialEntries={["/s/ghost"]}>
<Switcher state="connecting" />
</MemoryRouter>,
);
rerender(
<MemoryRouter initialEntries={["/s/ghost"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await waitFor(() => expect(ghostAfter).toBe("0"));
await act(async () => {
releaseRefetch?.([staleFrame]);
});
expect(screen.queryByText("stale s1 frame")).toBeNull();
});
it("ChatStream remounts on session switch (scroll pin reset, S4)", async () => {
mockFetchJson(() => []);
render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Switcher state="open" />
</MemoryRouter>,
);
await screen.findByRole("button", { name: "switch session" });
const firstScroller = document.querySelector(".chat-scroll");
expect(firstScroller).not.toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "switch session" }),
);
await screen.findByText("ghost");
expect(document.querySelector(".chat-scroll")).not.toBe(firstScroller);
});
it("failed send restores the draft (S6)", async () => {
mockFetchJson((_url, init) =>
init?.method === "POST" ? jsonResponse({ error: "nope" }, 500) : [],
);
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
await userEvent.type(ta, "precious draft");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
expect(ta.value).toBe("precious draft");
});
});
describe("ChatView unknown session", () => {
it("unknown id falls back to id title and hides agent-only controls", async () => {
mockFetchJson((url) => {
@@ -505,7 +832,9 @@ describe("ChatView usage chip", () => {
seq = 0;
return [
...historyEvents(),
ev("agent_end", { usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 } }),
ev("agent_end", {
usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 },
}),
];
}
return [];
@@ -518,7 +847,8 @@ describe("ChatView usage chip", () => {
it("usage chip hidden when no usage seen", async () => {
mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents();
return [];
});
renderChat(makeStore());
+53 -7
View File
@@ -15,6 +15,7 @@ import ChatStream from "./ChatStream";
import TaskPanel from "./TaskPanel";
const HISTORY_LIMIT: number = 1000;
const LATEST_QUERY: string = "latest=1";
const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter";
@@ -32,10 +33,15 @@ export default function ChatView({ store, pushToast }: Props) {
const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false);
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const [hasOlder, setHasOlder] = useState<boolean>(false);
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null);
// guards async fetches against session switches (S1)
const sessionIdRef = useRef<string>(sessionId);
sessionIdRef.current = sessionId;
const session = store.sessions.find((s) => s.id === sessionId);
@@ -62,21 +68,23 @@ export default function ChatView({ store, pushToast }: Props) {
});
}, []);
// history load on mount / session switch
// history load on mount / session switch: newest page, ascending (B1)
useEffect(() => {
if (sessionId.length === 0) return;
loadedRef.current = false;
setLoadError("");
setEvents([]);
setHasOlder(false);
lastSeqRef.current = 0;
let alive = true;
void (async () => {
try {
const evts = await fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
`${Route.SessionEvents(sessionId)}?${LATEST_QUERY}&limit=${HISTORY_LIMIT}`,
);
if (!alive) return;
applyEvents(evts);
setHasOlder(evts.length >= HISTORY_LIMIT);
} catch (err) {
if (alive) setLoadError(errMessage(err));
} finally {
@@ -94,20 +102,52 @@ export default function ChatView({ store, pushToast }: Props) {
return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]);
// missed events after reconnect (persisted only)
// missed events after reconnect (persisted only); a response for a
// previous session must not merge here nor touch the cursor (S1)
useEffect(() => {
if (store.state !== "open" || !loadedRef.current) return;
const id: string = sessionId;
const after: number = lastSeqRef.current;
void fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`,
`${Route.SessionEvents(id)}?after=${after}&limit=${HISTORY_LIMIT}`,
)
.then(applyEvents)
.then((evts) => {
if (sessionIdRef.current === id) applyEvents(evts);
})
.catch(() => undefined);
}, [store.state, sessionId, applyEvents]);
const chat = useMemo(() => deriveChat(events), [events]);
const tasks = useMemo(() => deriveTasks(events), [events]);
// a closed/crashed container can never emit agent_settled: an offline
// session must never look busy (B2)
const busy: boolean = chat.busy && session?.online !== false;
const minSeq: number = useMemo(
() => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))),
[events],
);
// previous page (seq < oldest loaded), ascending (B1)
const loadOlder = useCallback(async (): Promise<void> => {
// the button only renders while a full page is loaded, so minSeq > 0
if (sessionIdRef.current !== sessionId || loadingOlder) return;
setLoadingOlder(true);
try {
const evts = await fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?before=${minSeq}&limit=${HISTORY_LIMIT}`,
);
if (sessionIdRef.current !== sessionId) return;
applyEvents(evts);
setHasOlder(evts.length >= HISTORY_LIMIT);
} catch (err) {
pushToast(errMessage(err));
} finally {
setLoadingOlder(false);
}
}, [sessionId, minSeq, loadingOlder, applyEvents, pushToast]);
const autosize = useCallback((): void => {
const el = taRef.current;
if (el === null) return;
@@ -128,6 +168,8 @@ export default function ChatView({ store, pushToast }: Props) {
body: JSON.stringify({ message: text }),
});
} catch (err) {
// the message never reached the session: put it back (S6)
setDraft(text);
if (err instanceof ApiError && err.status === 409)
pushToast("session offline");
else pushToast(errMessage(err));
@@ -215,9 +257,13 @@ export default function ChatView({ store, pushToast }: Props) {
</div>
) : (
<ChatStream
key={sessionId}
messages={chat.messages}
tools={chat.tools}
busy={chat.busy}
busy={busy}
hasOlder={hasOlder}
loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()}
/>
)}
<div className="composer">
@@ -231,7 +277,7 @@ export default function ChatView({ store, pushToast }: Props) {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
{chat.busy ? (
{busy ? (
<button
type="button"
className="abort-btn"
+50 -6
View File
@@ -61,20 +61,22 @@ describe("SessionsView", () => {
it("empty state message (after load window)", () => {
vi.useFakeTimers();
renderView({ sessions: [] });
act(() => { vi.advanceTimersByTime(700); });
act(() => {
vi.advanceTimersByTime(700);
});
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
vi.useRealTimers();
});
it("skeleton while first load pending", () => {
renderView({ sessions: [] });
expect(screen.getByRole("heading", { name: "Active sessions" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Active sessions" }),
).toBeInTheDocument();
const skel = document.querySelector(".skeleton");
expect(skel).not.toBeNull();
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
@@ -181,12 +183,54 @@ describe("SessionsView", () => {
</Routes>
</MemoryRouter>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
const ev = fireEvent.keyDown(
screen.getByRole("button", { name: "Open session s8" }),
{
key: " ",
});
},
);
expect(ev).toBe(false); // preventDefault consumed the default action
expect(probe).toHaveBeenCalledWith("/s/s8");
});
it("Space and Enter keydown are default-prevented so the page does not scroll (S8)", () => {
const probe = vi.fn();
const renderKb = (): HTMLElement => {
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "kb2" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
return screen.getByRole("button", { name: "Open session kb2" });
};
// document-level bubble listener observes the event AFTER React's
// delegated handler, so defaultPrevented reflects the component's call
const seen: boolean[] = [];
const rec = (e: Event): void => {
seen.push(e.defaultPrevented);
};
document.addEventListener("keydown", rec);
// Space: navigation unmounts the card, so each key gets a fresh render
fireEvent.keyDown(renderKb(), { key: " " });
fireEvent.keyDown(renderKb(), { key: "Enter" });
fireEvent.keyDown(renderKb(), { key: "Tab" });
document.removeEventListener("keydown", rec);
expect(seen).toEqual([true, true, false]);
expect(probe).toHaveBeenCalledWith("/s/kb2");
});
it("stop with unnamed session toasts the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
+5 -1
View File
@@ -28,7 +28,11 @@ function SessionCard({
aria-label={`Open session ${s.name ?? s.id}`}
onClick={() => onOpen(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpen(s.id);
if (e.key === "Enter" || e.key === " ") {
// Space/Enter on a role=button must not scroll the page
e.preventDefault();
onOpen(s.id);
}
}}
>
<span
+365 -413
View File
@@ -1,448 +1,400 @@
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";
import { fireEvent, render, screen, waitFor } 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";
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 {
function msg(partial: Partial<ChatMessage>): ChatMessage {
return {
sessions: [],
state: "open",
spawnJobs: [],
refresh: async (): Promise<void> => undefined,
subscribe: (): (() => void) => () => undefined,
...over,
key: `k-${Math.random()}`,
role: "assistant",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
...partial,
};
}
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>
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()}
/>,
);
}
/** 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();
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there");
});
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("shows loading repos while the list is pending", async () => {
const gate = { resolve: null as ((v: unknown) => void) | null };
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return new Promise((res) => {
gate.resolve = res;
});
return [];
});
render(tree(makeStore()));
await flush();
expect(screen.getByText("loading repos…")).toBeInTheDocument();
gate.resolve?.([repo("g/late")]);
await flush();
expect(await screen.findByText("g/late")).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]> = [];
let sessionsOnline = false; // flipped after the first poll tick
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/sessions"))
return sessionsOnline
? [
{
id: "new-1",
name: "spawned",
cwd: "/w",
model: "m",
provider: "p",
agent: true,
repo: "g/proj",
startedAt: 1,
online: true,
lastEventAt: 1,
},
]
: [];
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" }),
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(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
sessionsOnline = true;
await act(async () => {
vi.advanceTimersByTime(1500);
});
await flush();
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
unmount();
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
});
it("spawning view shows job line from status and spawn error text", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sx", containerId: "" };
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 [];
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);
});
const store = makeStore({
spawnJobs: [
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
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(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
]);
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("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" };
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", () => {
function stream(p: {
messages: ChatMessage[];
busy: boolean;
hasOlder?: boolean;
loadingOlder?: boolean;
onOlder?: () => void;
}): React.ReactElement {
return (
<ChatStream
messages={p.messages}
tools={new Map()}
busy={p.busy}
hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)}
/>
);
}
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("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render(
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
);
expect(container.querySelector(".typing")).not.toBeNull();
rerender(
stream({
messages: [
msg({ key: "a", text: "one" }),
msg({ key: "b", streaming: true }),
],
busy: true,
}),
);
expect(container.querySelector(".typing")).toBeNull();
rerender(
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
);
expect(container.querySelector(".typing")).toBeNull();
});
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 [];
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
const { container, rerender } = render(
stream({ messages: [msg({ key: "a" })], busy: false }),
);
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 1000,
});
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();
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
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 [];
scroller.dispatchEvent(new Event("scroll"));
// pinned: scrollTop at bottom
scroller.scrollTop = 700;
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 700,
});
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();
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [msg({ key: "a" }), msg({ key: "b" })],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(1000);
await act(async () => {
vi.advanceTimersByTime(1500 * 402);
// scroll far up -> unpin
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 0,
});
await flush();
const afterCap = statusCalls;
await act(async () => {
vi.advanceTimersByTime(1500 * 10);
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(0);
// scroll near bottom (within 80px) -> pinned again
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 940,
});
await flush();
expect(statusCalls).toBe(afterCap);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
unmount();
scroller.dispatchEvent(new Event("scroll"));
rerender(
stream({
messages: [
msg({ key: "a" }),
msg({ key: "b" }),
msg({ key: "c" }),
msg({ key: "d" }),
],
busy: false,
}),
);
expect(scroller.scrollTop).toBe(1000);
});
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();
it("load-older button renders only when an older page exists and fires on click", () => {
const onOlder = vi.fn();
const { rerender } = render(
stream({ messages: [msg({ key: "a" })], busy: false }),
);
expect(
screen.queryByRole("button", { name: "Load older messages" }),
).toBeNull();
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
act(() => {
rerender(tree(store));
});
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
rerender(
stream({
messages: [msg({ key: "a" })],
busy: false,
hasOlder: true,
onOlder,
}),
);
const btn = screen.getByRole("button", { name: "Load older messages" });
fireEvent.click(btn);
expect(onOlder).toHaveBeenCalledTimes(1);
rerender(
stream({
messages: [msg({ key: "a" })],
busy: false,
hasOlder: true,
loadingOlder: true,
onOlder,
}),
);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeDisabled();
});
});
describe("SpawnSteps", () => {
it("renders progress steps matching job state", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sp1", containerId: "" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
describe("copy button", () => {
it("copy button writes message text and flashes copied", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
render(
<Bubble
msg={{
key: "k",
role: "assistant",
text: "copy me",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
}}
tools={new Map()}
/>,
);
const btn = screen.getByRole("button", { name: "Copy message" });
fireEvent.click(btn);
expect(writeText).toHaveBeenCalledWith("copy me");
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
});
const store = makeStore({
spawnJobs: [{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" }],
});
render(tree(store));
await flush();
fireEvent.click(screen.getByText("g/p"));
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
const steps = document.querySelectorAll(".spawn-progress .step");
expect(steps).toHaveLength(4);
expect(steps[0]?.className).toContain("done");
expect(steps[1]?.className).toContain("current");
expect(steps[2]?.className).toBe("step");
it("user bubbles get a copy button, toolResults do not", () => {
const { rerender } = render(
<Bubble
msg={{
key: "u",
role: "user",
text: "hi",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
}}
tools={new Map()}
/>,
);
expect(
screen.getByRole("button", { name: "Copy message" }),
).toBeInTheDocument();
rerender(
<Bubble
msg={{
key: "t",
role: "toolResult",
text: "r",
thinking: null,
toolCalls: [],
toolCallId: "c1",
streaming: false,
}}
tools={new Map()}
/>,
);
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
});
});
+11 -11
View File
@@ -1,12 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type {
GitlabStatus,
Repo,
SessionListItem,
SpawnJob,
SpawnResponse,
} from "./protocol";
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
import { Route } from "./protocol";
import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store";
@@ -19,14 +13,21 @@ interface Props {
pushToast: (text: string) => void;
}
const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"];
function SpawnSteps({ state }: { state: string }): React.ReactNode {
const idx = SPAWN_STEPS.indexOf(state);
if (state === "error") return null;
const currentStepIndex: number = idx >= 0 ? idx : 0;
return (
<div className="spawn-progress" aria-label={`spawn progress: ${state}`}>
<div
className="spawn-progress"
role="progressbar"
aria-label={`spawn progress: ${state}`}
aria-valuemin={1}
aria-valuemax={SPAWN_STEPS.length}
aria-valuenow={currentStepIndex + 1}
>
{SPAWN_STEPS.map((step, i) => (
<span
key={step}
@@ -151,9 +152,8 @@ export default function SpawnView({ store, pushToast }: Props) {
}
void (async () => {
try {
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
const list = await store.refresh();
const s = list.find((x) => x.id === sessionId);
await store.refresh();
if (s !== undefined && s.online) {
if (timerRef.current !== null)
window.clearInterval(timerRef.current);
+13
View File
@@ -475,6 +475,15 @@ a:hover {
max-width: 780px;
margin: 0 auto;
}
.load-older {
display: flex;
justify-content: center;
padding: 0 0 14px;
}
.load-older .btn-secondary {
padding: 5px 16px;
font-size: 12.5px;
}
.bubble-row {
display: flex;
@@ -523,6 +532,10 @@ a:hover {
.bubble-row:hover .msg-copy {
opacity: 1;
}
.bubble-row .msg-copy:focus-visible,
.bubble-row:focus-within .msg-copy {
opacity: 1;
}
.bubble-row.user .msg-copy {
right: auto;
left: -4px;
+189 -4
View File
@@ -4,11 +4,12 @@ import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store";
import {
FakeWebSocket,
jsonResponse,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { EventFrame } from "./protocol";
import type { EventFrame, SessionListItem } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
@@ -74,7 +75,13 @@ describe("useSessions", () => {
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 sessions: SessionListItem[] = [
{
id: "s1",
name: "one",
online: true,
},
] as SessionListItem[];
const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions;
@@ -108,13 +115,17 @@ describe("useSessions", () => {
{ repo: "g/p", state: "cloning" },
]);
// refresh resolves to the fetched rows (S7) and re-seeds the list
let out: SessionListItem[] | undefined;
await act(async () => {
await result.current?.refresh();
out = await result.current?.refresh();
});
expect(out).toEqual(sessions);
expect(result.current?.sessions).toEqual(sessions);
expect(fetchMock).toHaveBeenCalled();
});
it("refresh failure pushes a toast", async () => {
it("refresh failure pushes a toast and resolves to an empty list", async () => {
seedSettings();
mockFetchJson(() => {
throw new Error("network down");
@@ -125,6 +136,11 @@ describe("useSessions", () => {
expect(push).toHaveBeenCalledWith("sessions: network down"),
);
expect(result.current).not.toBeNull();
let out: SessionListItem[] | undefined;
await act(async () => {
out = await result.current?.refresh();
});
expect(out).toEqual([]);
});
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
@@ -192,6 +208,130 @@ describe("useSessions", () => {
loc.restore();
});
it("3 failed connects + 401 REST probe clears settings and reloads (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
mockFetchJson((url) =>
url.includes("/api/sessions")
? jsonResponse({ error: "unauthorized" }, 401)
: [],
);
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect(); // 3rd consecutive connect failure -> probe -> 401
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
expect(getSettings()).toBeNull();
expect(loc.reload).toHaveBeenCalled();
loc.restore();
vi.useRealTimers();
});
it("probe failing with a non-401 error never clears settings (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
mockFetchJson((url) => {
if (url.includes("/api/sessions")) throw new Error("daemon unreachable");
return [];
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect();
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
expect(getSettings()).not.toBeNull();
expect(loc.reload).not.toHaveBeenCalled();
loc.restore();
vi.useRealTimers();
});
it("successful probe resets the failure counter, no clear (S3)", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(1);
seedSettings();
let sessionsCalls = 0;
mockFetchJson((url) => {
if (url.includes("/api/sessions")) {
sessionsCalls += 1;
return [];
}
return [];
});
const loc = stubReload();
const push = vi.fn();
renderHook(() => useSessions(push));
const failConnect = (): void => {
act(() => FakeWebSocket.last().serverClose(1006));
};
const flush = async (): Promise<void> => {
for (let i = 0; i < 8; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
};
failConnect();
act(() => {
vi.advanceTimersByTime(500);
});
failConnect();
act(() => {
vi.advanceTimersByTime(1000);
});
failConnect();
await flush();
// mount seed (1) + exactly one probe (2); nothing else
expect(sessionsCalls).toBe(2);
expect(loc.reload).not.toHaveBeenCalled();
expect(getSettings()).not.toBeNull();
// counter reset: two more failures stay under the threshold, no probe
failConnect();
act(() => {
vi.advanceTimersByTime(2000);
});
failConnect();
await flush();
expect(sessionsCalls).toBe(2);
loc.restore();
vi.useRealTimers();
});
it("unmount closes the manager", async () => {
seedSettings();
mockFetchJson(() => []);
@@ -203,6 +343,51 @@ describe("useSessions", () => {
expect(sock.closeCode).toBe(4900);
expect(sock.onclose).toBeNull();
});
it("returned store object keeps identity when only unrelated state changes (S2)", async () => {
seedSettings();
// a stable list reference: only identity behavior is under test here
const stable: SessionListItem[] = [];
mockFetchJson(() => stable);
const push = vi.fn();
const { result, rerender } = renderHook(() => useSessions(push));
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
// let the mount-time seed settle
for (let i = 0; i < 4; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
const first = result.current;
rerender();
rerender();
expect(result.current).toBe(first); // memoized, not a fresh object
});
it("store identity changes when the sessions list changes", async () => {
seedSettings();
let list: SessionListItem[] = [];
mockFetchJson(() => list);
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
await waitFor(() => expect(result.current).not.toBeNull());
const first = result.current;
const sock = FakeWebSocket.last();
act(() => sock.serverOpen());
list = [
{
id: "s9",
name: "x",
online: true,
} as SessionListItem,
];
await act(async () => {
await result.current?.refresh();
});
expect(result.current).not.toBe(first);
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s9"]);
});
});
describe("settings persistence used by the store", () => {
+45 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
EventFrame,
ServerFrame,
@@ -8,13 +8,15 @@ import type {
import { Route } from "./protocol";
import { buildWsUrl, getSettings, clearSettings } from "./settings";
import { createWsManager, type WsManager, type WsState } from "./ws";
import { errMessage, fetchJson } from "./api";
import { ApiError, errMessage, fetchJson } from "./api";
// ---------- small shared helpers ----------
const MINUTE_MS: number = 60_000;
const HOUR_MS: number = 60 * MINUTE_MS;
const DAY_MS: number = 24 * HOUR_MS;
/** consecutive ws connect failures before probing REST auth (S3) */
const AUTH_PROBE_FAILURES: number = 3;
export function relativeTime(ts: number | null): string {
if (ts === null) return "never";
@@ -60,7 +62,8 @@ export interface SessionsStore {
sessions: SessionListItem[];
state: WsState;
spawnJobs: SpawnJob[];
refresh: () => Promise<void>;
/** Fetches the list; resolves to the fetched rows (empty on failure). */
refresh: () => Promise<SessionListItem[]>;
/** Subscribe to one session's event stream (protocol allows one at a time). */
subscribe: (
sessionId: string,
@@ -88,21 +91,46 @@ export function useSessions(
if (frame.type === "session_list") setSessions(frame.sessions);
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
});
m.onAuthError(() => {
const handleAuthError = (): void => {
clearSettings();
window.location.reload();
};
m.onAuthError(handleAuthError);
// daemon rejects /ws pre-upgrade with 401: the browser only ever sees
// close 1006, so after repeated connect failures probe REST auth (S3)
let connectFailures = 0;
const offFail = m.onState((s) => {
if (s === "open") {
connectFailures = 0;
return;
}
if (s !== "closed") return;
connectFailures += 1;
if (connectFailures < AUTH_PROBE_FAILURES) return;
void fetchJson(Route.Sessions)
.then(() => {
connectFailures = 0;
})
.catch((err: unknown) => {
if (err instanceof ApiError && err.status === 401) handleAuthError();
});
});
const refresh = async (): Promise<void> => {
const refresh = async (): Promise<SessionListItem[]> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
setSessions(list);
return list;
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
return [];
}
};
void refresh();
return () => {
offFail();
offState();
offFrames();
m.close();
@@ -110,11 +138,14 @@ export function useSessions(
};
}, [pushToast]);
const refresh = useCallback(async (): Promise<void> => {
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
setSessions(list);
return list;
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
return [];
}
}, [pushToast]);
@@ -137,9 +168,14 @@ export function useSessions(
[manager],
);
const store = useMemo(
() => ({ sessions, state, spawnJobs, refresh, subscribe }),
[sessions, state, spawnJobs, refresh, subscribe],
);
// 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 };
return store;
}