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:
+21
-3
@@ -169,7 +169,27 @@ func (s *Server) handleSessionEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
if limit > maxEventsLimit {
|
if limit > maxEventsLimit {
|
||||||
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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).
|
// daemonToken is set by main from LVMH_TOKEN (single process, read-only).
|
||||||
var daemonToken string
|
var daemonToken string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package main
|
|||||||
// serving, body/token validation edges.
|
// serving, body/token validation edges.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -37,7 +38,7 @@ func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) {
|
|||||||
daemonToken = testToken
|
daemonToken = testToken
|
||||||
store := openTestStore(t)
|
store := openTestStore(t)
|
||||||
hub := NewHub(store)
|
hub := NewHub(store)
|
||||||
sp, err := NewSpawner(store, hub, "https://gitlab.example")
|
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
t.Fatalf("NewSpawner: %v", err)
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ func TestAPISpawnFailures(t *testing.T) {
|
|||||||
daemonToken = testToken
|
daemonToken = testToken
|
||||||
store := openTestStore(t)
|
store := openTestStore(t)
|
||||||
hub := NewHub(store)
|
hub := NewHub(store)
|
||||||
sp, err := NewSpawner(store, hub, "https://gitlab.example")
|
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
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) {
|
func TestAPIEventsEdgeCases(t *testing.T) {
|
||||||
ts, store := newTestServer(t)
|
ts, store := newTestServer(t)
|
||||||
auth := testToken
|
auth := testToken
|
||||||
|
|||||||
+111
-29
@@ -12,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -61,6 +62,7 @@ const (
|
|||||||
envLVMHRepo string = "LVMH_REPO"
|
envLVMHRepo string = "LVMH_REPO"
|
||||||
stopTimeoutSeconds int = 10
|
stopTimeoutSeconds int = 10
|
||||||
buildContextReadLimit int64 = 64 << 20
|
buildContextReadLimit int64 = 64 << 20
|
||||||
|
maxSpawnJobs int = 50 // jobs map pruned to this many entries
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNoContainer = errors.New("no container for session")
|
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).
|
// validRepoPath accepts "group/project" style paths (at least two segments).
|
||||||
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
|
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.
|
// newUUID returns a random RFC 4122 v4 UUID string.
|
||||||
func newUUID() string {
|
func newUUID() string {
|
||||||
@@ -112,6 +114,7 @@ type SpawnResult struct {
|
|||||||
|
|
||||||
// Spawner owns the docker client, per-repo clone serialization and job state.
|
// Spawner owns the docker client, per-repo clone serialization and job state.
|
||||||
type Spawner struct {
|
type Spawner struct {
|
||||||
|
ctx context.Context // base ctx for async jobs (cancelled on shutdown)
|
||||||
store *Store
|
store *Store
|
||||||
hub *Hub
|
hub *Hub
|
||||||
cli *client.Client
|
cli *client.Client
|
||||||
@@ -125,16 +128,18 @@ type Spawner struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs map[string]*SpawnJob // keyed by sessionId
|
jobs map[string]*SpawnJob // keyed by sessionId
|
||||||
|
jobOrder []string // insertion order of jobs, for pruning
|
||||||
slugLocks map[string]*sync.Mutex
|
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())
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("docker client: %w", err)
|
return nil, fmt.Errorf("docker client: %w", err)
|
||||||
}
|
}
|
||||||
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
|
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
|
||||||
return &Spawner{
|
return &Spawner{
|
||||||
|
ctx: ctx,
|
||||||
store: store,
|
store: store,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
cli: cli,
|
cli: cli,
|
||||||
@@ -173,6 +178,13 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
j = &SpawnJob{SessionID: sessionID, Repo: repo}
|
j = &SpawnJob{SessionID: sessionID, Repo: repo}
|
||||||
s.jobs[sessionID] = j
|
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.Repo = repo
|
||||||
j.State = state
|
j.State = state
|
||||||
@@ -183,6 +195,23 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
|
|||||||
s.hub.BroadcastSpawnStatus()
|
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.
|
// Start launches the async spawn pipeline and returns the new sessionId.
|
||||||
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
|
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
|
||||||
if _, err := s.imageExists(ctx); err != nil {
|
if _, err := s.imageExists(ctx); err != nil {
|
||||||
@@ -234,12 +263,12 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setJob(sessionID, repo, stateBuilding, "", "")
|
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())
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setJob(sessionID, repo, stateCreating, "", "")
|
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 {
|
if err != nil {
|
||||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
return
|
return
|
||||||
@@ -252,6 +281,8 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cloneOrUpdate clones the repo into reposDir/<slug> or fast-forwards it.
|
// 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 {
|
func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
||||||
dir := filepath.Join(s.reposDir, slug)
|
dir := filepath.Join(s.reposDir, slug)
|
||||||
cloneURL, err := s.cloneURL(repo)
|
cloneURL, err := s.cloneURL(repo)
|
||||||
@@ -259,7 +290,16 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
|
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 fmt.Errorf("git pull %s: %w", repo, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -267,7 +307,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
|||||||
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
|
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
args := []string{"clone"}
|
args := append(s.gitAuthArgs(), "clone")
|
||||||
if branch != "" {
|
if branch != "" {
|
||||||
args = append(args, "--branch", branch)
|
args = append(args, "--branch", branch)
|
||||||
}
|
}
|
||||||
@@ -278,18 +318,34 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
|||||||
return nil
|
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) {
|
func (s *Spawner) cloneURL(repo string) (string, error) {
|
||||||
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
|
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil {
|
|
||||||
u.User = url.User(token)
|
|
||||||
}
|
|
||||||
return u.String(), nil
|
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 {
|
func gitRun(dir string, args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
@@ -327,17 +383,29 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
pr, pw := io.Pipe()
|
||||||
if err := tarDir(&buf, s.buildContext); err != nil {
|
tarDone := make(chan error, 1)
|
||||||
return fmt.Errorf("build context %s: %w", s.buildContext, err)
|
go func() {
|
||||||
}
|
err := tarDir(pw, s.buildContext)
|
||||||
resp, err := s.cli.ImageBuild(ctx, &buf, build.ImageBuildOptions{
|
_ = pw.CloseWithError(err)
|
||||||
|
tarDone <- err
|
||||||
|
}()
|
||||||
|
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
|
||||||
Tags: []string{imageRefWorker},
|
Tags: []string{imageRefWorker},
|
||||||
Dockerfile: relDockerfile,
|
Dockerfile: relDockerfile,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if buildErr != nil {
|
||||||
return fmt.Errorf("docker build: %w", err)
|
_ = 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()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
|
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 fresh {
|
||||||
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
|
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)
|
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,
|
Binds: binds,
|
||||||
NetworkMode: container.NetworkMode(s.network),
|
NetworkMode: container.NetworkMode(s.network),
|
||||||
AutoRemove: false,
|
AutoRemove: false,
|
||||||
|
Init: &[]bool{true}[0], // tini reaps bridge setup-hook zombies
|
||||||
}
|
}
|
||||||
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
|
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
|
||||||
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
|
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{
|
hostCfg := &container.HostConfig{
|
||||||
Binds: []string{repoVolume + ":" + workspaceMount},
|
Binds: []string{repoVolume + ":" + workspaceMount},
|
||||||
|
Init: &[]bool{true}[0],
|
||||||
}
|
}
|
||||||
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
|
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer s.removeContainer(context.Background(), created.ID)
|
defer s.removeContainer(context.Background(), created.ID)
|
||||||
var buf bytes.Buffer
|
pr, pw := io.Pipe()
|
||||||
if err := tarDir(&buf, repoDir); err != nil {
|
tarDone := make(chan error, 1)
|
||||||
return fmt.Errorf("tar clone: %w", err)
|
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 {
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
return fmt.Errorf("copy into volume: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -484,13 +572,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
|
|||||||
if err := s.store.DeleteContainer(sessionID); err != nil {
|
if err := s.store.DeleteContainer(sessionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.deleteJob(sessionID)
|
||||||
if j, ok := s.jobs[sessionID]; ok {
|
|
||||||
j.State = stateError
|
|
||||||
j.Message = "container removed"
|
|
||||||
j.UpdatedAt = time.Now().UnixMilli()
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
s.hub.BroadcastSpawnStatus()
|
s.hub.BroadcastSpawnStatus()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -4,6 +4,7 @@ package main
|
|||||||
// fake git binary (PATH shim recording invocations).
|
// fake git binary (PATH shim recording invocations).
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -38,6 +39,7 @@ type recordedCreate struct {
|
|||||||
HostConfig struct {
|
HostConfig struct {
|
||||||
Binds []string `json:"Binds"`
|
Binds []string `json:"Binds"`
|
||||||
NetworkMode string `json:"NetworkMode"`
|
NetworkMode string `json:"NetworkMode"`
|
||||||
|
Init *bool `json:"Init"`
|
||||||
} `json:"HostConfig"`
|
} `json:"HostConfig"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ type fakeDocker struct {
|
|||||||
failStop bool
|
failStop bool
|
||||||
failWait bool
|
failWait bool
|
||||||
failVolumeCreate bool
|
failVolumeCreate bool
|
||||||
|
failVolumeDelete bool
|
||||||
failArchive bool
|
failArchive bool
|
||||||
archiveHang bool
|
archiveHang bool
|
||||||
waitHang bool
|
waitHang bool
|
||||||
@@ -205,6 +208,16 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
|
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":
|
case call.Method == http.MethodPost && call.Path == "/containers/create":
|
||||||
var rc recordedCreate
|
var rc recordedCreate
|
||||||
if err := json.Unmarshal(body, &rc); err != nil {
|
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" +
|
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
|
||||||
" silent) exit 1;;\n" +
|
" silent) exit 1;;\n" +
|
||||||
"esac\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" +
|
"# 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"
|
"exit 0\n"
|
||||||
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
|
||||||
t.Fatalf("write fake git: %v", err)
|
t.Fatalf("write fake git: %v", err)
|
||||||
@@ -345,7 +361,7 @@ func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
|
|||||||
daemonToken = testToken
|
daemonToken = testToken
|
||||||
store := openTestStore(t)
|
store := openTestStore(t)
|
||||||
hub := NewHub(store)
|
hub := NewHub(store)
|
||||||
sp, err := NewSpawner(store, hub, "https://gitlab.example/")
|
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
t.Fatalf("NewSpawner: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -18,6 +18,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
settingGitLabToken string = "gitlab_pat"
|
settingGitLabToken string = "gitlab_pat"
|
||||||
projectsPerPage int = 50
|
projectsPerPage int = 50
|
||||||
|
maxRepoPages int = 5 // pagination cap: 5 pages / 250 repos
|
||||||
gitlabTimeout time.Duration = 15 * time.Second
|
gitlabTimeout time.Duration = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,18 +134,20 @@ func (g *GitLab) token() (string, error) {
|
|||||||
return token, nil
|
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) {
|
func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
|
||||||
token, err := g.token()
|
token, err := g.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var projects []giteaRepo
|
||||||
path := fmt.Sprintf("/api/v1/user/repos?limit=%d", projectsPerPage)
|
|
||||||
if err := g.do(ctx, path, token, &projects); err != nil {
|
if err := g.do(ctx, path, token, &projects); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
repos := make([]GitLabRepo, 0, len(projects))
|
|
||||||
for _, p := range projects {
|
for _, p := range projects {
|
||||||
if p.FullName == "" {
|
if p.FullName == "" {
|
||||||
continue
|
continue
|
||||||
@@ -158,6 +161,10 @@ func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
|
|||||||
DefaultBranch: p.DefaultBranch,
|
DefaultBranch: p.DefaultBranch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if len(projects) < projectsPerPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
sort.SliceStable(repos, func(i, j int) bool {
|
sort.SliceStable(repos, func(i, j int) bool {
|
||||||
return repos[i].LastActivityAt > repos[j].LastActivityAt
|
return repos[i].LastActivityAt > repos[j].LastActivityAt
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -131,3 +133,63 @@ func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
|
|||||||
t.Fatalf("repos = %+v, want empty (blank path skipped)", repos)
|
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
@@ -45,7 +45,10 @@ const (
|
|||||||
webSendQueue int = 128
|
webSendQueue int = 128
|
||||||
webFlushInterval time.Duration = 40 * time.Millisecond
|
webFlushInterval time.Duration = 40 * time.Millisecond
|
||||||
webMaxPending int = 4096 // drop slow clients beyond this backlog
|
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
|
promptSendTimeout time.Duration = 3 * time.Second
|
||||||
|
pongWait time.Duration = 60 * time.Second
|
||||||
|
pingPeriod time.Duration = 30 * time.Second
|
||||||
writeWait time.Duration = 5 * time.Second
|
writeWait time.Duration = 5 * time.Second
|
||||||
daemonVersion int = 1 // envelope "v"
|
daemonVersion int = 1 // envelope "v"
|
||||||
)
|
)
|
||||||
@@ -113,6 +116,7 @@ type webClient struct {
|
|||||||
sub string // subscribed sessionId, "" when none
|
sub string // subscribed sessionId, "" when none
|
||||||
control [][]byte
|
control [][]byte
|
||||||
events []pendingEvent
|
events []pendingEvent
|
||||||
|
pendingBytes int
|
||||||
dropped bool
|
dropped bool
|
||||||
|
|
||||||
closeOnce sync.Once
|
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) {
|
func (c *webClient) deliverControl(b []byte) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.dropped {
|
if c.dropped {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(c.control)+len(c.events) >= webMaxPending {
|
if c.overflows(len(b)) {
|
||||||
c.dropped = true
|
c.dropped = true
|
||||||
go c.drop()
|
go c.drop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.control = append(c.control, b)
|
c.control = append(c.control, b)
|
||||||
|
c.pendingBytes += len(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *webClient) deliverEvent(e pendingEvent) {
|
func (c *webClient) deliverEvent(e pendingEvent) {
|
||||||
@@ -146,30 +157,68 @@ func (c *webClient) deliverEvent(e pendingEvent) {
|
|||||||
if c.dropped {
|
if c.dropped {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(c.control)+len(c.events) >= webMaxPending {
|
if c.overflows(len(e.raw)) {
|
||||||
c.dropped = true
|
c.dropped = true
|
||||||
go c.drop()
|
go c.drop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.events = append(c.events, e)
|
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
|
// 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() {
|
func (c *webClient) writePump() {
|
||||||
ticker := time.NewTicker(webFlushInterval)
|
ticker := time.NewTicker(webFlushInterval)
|
||||||
|
pings := time.NewTicker(pingPeriod)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
defer pings.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.done:
|
case <-c.done:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
case <-pings.C:
|
||||||
|
if !c.sendPing() {
|
||||||
|
c.hub.dropWeb(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
control := c.control
|
control := c.control
|
||||||
c.control = nil
|
c.control = nil
|
||||||
events := c.events
|
events := c.events
|
||||||
c.events = nil
|
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()
|
c.mu.Unlock()
|
||||||
for _, b := range control {
|
for _, b := range control {
|
||||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
@@ -178,35 +227,34 @@ func (c *webClient) writePump() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(events) == 0 {
|
for i := 0; i < len(events); {
|
||||||
continue
|
j := i
|
||||||
|
for j < len(events) && events[j].sessionID == events[i].sessionID {
|
||||||
|
j++
|
||||||
}
|
}
|
||||||
items := make([]json.RawMessage, 0, len(events))
|
if err := c.writeEventsFrame(events[i:j]); err != nil {
|
||||||
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 {
|
|
||||||
c.hub.dropWeb(c)
|
c.hub.dropWeb(c)
|
||||||
return
|
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.
|
// readPump consumes subscribe/unsubscribe frames; malformed input never kills the server.
|
||||||
func (c *webClient) readPump() {
|
func (c *webClient) readPump() {
|
||||||
defer c.hub.dropWeb(c)
|
defer c.hub.dropWeb(c)
|
||||||
c.conn.SetReadLimit(maxFrameSize)
|
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 {
|
for {
|
||||||
_, data, err := c.conn.ReadMessage()
|
_, data, err := c.conn.ReadMessage()
|
||||||
if err != nil {
|
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() {
|
func (a *agentConn) writePump() {
|
||||||
|
pings := time.NewTicker(pingPeriod)
|
||||||
|
defer pings.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-a.done:
|
case <-a.done:
|
||||||
@@ -264,9 +315,20 @@ func (a *agentConn) writePump() {
|
|||||||
a.drop()
|
a.drop()
|
||||||
return
|
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
|
// Hub tracks live agent conns and web subscribers; it is the only component
|
||||||
// that mutates online state.
|
// that mutates online state.
|
||||||
@@ -418,6 +480,10 @@ func (h *Hub) ServeAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Hub) readAgentLoop(ac *agentConn) {
|
func (h *Hub) readAgentLoop(ac *agentConn) {
|
||||||
defer ac.drop()
|
defer ac.drop()
|
||||||
ac.conn.SetReadLimit(maxFrameSize)
|
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
|
registered := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if registered {
|
if registered {
|
||||||
@@ -543,11 +609,15 @@ func (h *Hub) handleEvent(f frame) {
|
|||||||
|
|
||||||
func (h *Hub) unregister(ac *agentConn) {
|
func (h *Hub) unregister(ac *agentConn) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
removed := false
|
||||||
if cur, ok := h.agents[ac.sessionID]; ok && cur == ac {
|
if cur, ok := h.agents[ac.sessionID]; ok && cur == ac {
|
||||||
delete(h.agents, ac.sessionID)
|
delete(h.agents, ac.sessionID)
|
||||||
|
removed = true
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
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 {
|
if err := h.store.SetOnline(ac.sessionID, false); err != nil {
|
||||||
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
|
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,3 +491,226 @@ func TestWebClientDeliverToDropped(t *testing.T) {
|
|||||||
t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending)
|
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
@@ -67,7 +67,9 @@ func run(args []string) error {
|
|||||||
|
|
||||||
hub := NewHub(store)
|
hub := NewHub(store)
|
||||||
gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase))
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("docker client: %w", err)
|
return fmt.Errorf("docker client: %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,10 +96,12 @@ func run(args []string) error {
|
|||||||
defer signal.Stop(stop)
|
defer signal.Stop(stop)
|
||||||
select {
|
select {
|
||||||
case err := <-serveErr:
|
case err := <-serveErr:
|
||||||
|
spawnerCtxCancel()
|
||||||
return fmt.Errorf("listen: %w", err)
|
return fmt.Errorf("listen: %w", err)
|
||||||
case <-stop:
|
case <-stop:
|
||||||
}
|
}
|
||||||
log.Printf("shutting down")
|
log.Printf("shutting down")
|
||||||
|
spawnerCtxCancel()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := httpServer.Shutdown(ctx); err != nil {
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
|
|||||||
+220
-20
@@ -75,7 +75,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
t.Fatalf("missing env %v", wantEnv)
|
t.Fatalf("missing env %v", wantEnv)
|
||||||
}
|
}
|
||||||
wantBinds := []string{
|
wantBinds := []string{
|
||||||
"lvmh-repo-group-project:" + workspaceMount,
|
"lvmh-repo-group--project:" + workspaceMount,
|
||||||
volumeSessions + ":" + sessionsMount,
|
volumeSessions + ":" + sessionsMount,
|
||||||
volumePiCache + ":" + cacheMount,
|
volumePiCache + ":" + cacheMount,
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,9 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
if c.HostConfig.NetworkMode != defaultNetwork {
|
if c.HostConfig.NetworkMode != defaultNetwork {
|
||||||
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
|
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)
|
// fresh repo volume seeded from the clone via CopyToContainer (tar)
|
||||||
seed := f.createsByName("lvmh-seed-")
|
seed := f.createsByName("lvmh-seed-")
|
||||||
@@ -99,10 +102,13 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
if seed[0].Image != imageRefWorker {
|
if seed[0].Image != imageRefWorker {
|
||||||
t.Fatalf("seed create = %+v", seed[0])
|
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) {
|
if !reflect.DeepEqual(seed[0].HostConfig.Binds, wantSeedBinds) {
|
||||||
t.Fatalf("seed binds = %v, want %v (no host-path binds)", 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 {
|
if len(f.archives) != 1 || f.archives[0] == 0 {
|
||||||
t.Fatalf("CopyToContainer archives = %v, want one non-empty tar", f.archives)
|
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
|
// docker reachable but image absent and no Dockerfile anywhere → clear error
|
||||||
t.Setenv(envWorkerDockerfile, filepath.Join(t.TempDir(), "missing.Dockerfile"))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
t.Fatalf("NewSpawner: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,7 +256,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
|
|||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
// existing clone → pull --ff-only in that dir, no clone
|
// 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 {
|
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir .git: %v", err)
|
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()
|
f := newFakeDocker()
|
||||||
sp, store := newTestSpawner(t, f)
|
sp, store := newTestSpawner(t, f)
|
||||||
|
|
||||||
@@ -273,12 +279,13 @@ func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
|
|||||||
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
|
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
|
||||||
t.Fatalf("set token: %v", err)
|
t.Fatalf("set token: %v", err)
|
||||||
}
|
}
|
||||||
|
// A stored PAT must never leak into the persisted clone URL.
|
||||||
u, err := sp.cloneURL("group/project")
|
u, err := sp.cloneURL("group/project")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("cloneURL: %v", err)
|
t.Fatalf("cloneURL: %v", err)
|
||||||
}
|
}
|
||||||
if u != "https://pat-1@gitlab.example/group/project.git" {
|
if u != "https://gitlab.example/group/project.git" || strings.Contains(u, "pat-1") {
|
||||||
t.Fatalf("cloneURL with PAT = %q", u)
|
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")
|
t.Fatal("container row must be deleted")
|
||||||
}
|
}
|
||||||
for _, j := range sp.JobsSnapshot() {
|
for _, j := range sp.JobsSnapshot() {
|
||||||
if j.SessionID == "s1" && j.State != stateError {
|
if j.SessionID == "s1" {
|
||||||
t.Fatalf("job after removal = %+v, want error state", j)
|
t.Fatalf("job after removal = %+v, want entry deleted", j)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,17 +333,17 @@ func TestSpawnerSeedVolumeCancel(t *testing.T) {
|
|||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
f.archiveHang = true // server never answers the archive PUT
|
f.archiveHang = true // server never answers the archive PUT
|
||||||
sp, _ := newTestSpawner(t, f)
|
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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
done := make(chan error, 1)
|
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") })
|
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPut, "/archive") })
|
||||||
cancel()
|
cancel()
|
||||||
select {
|
select {
|
||||||
@@ -379,9 +386,13 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRepoSlugAndUUID(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)
|
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()
|
id := newUUID()
|
||||||
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
|
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
|
||||||
t.Fatalf("newUUID shape = %q", id)
|
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) {
|
func TestEnvOr(t *testing.T) {
|
||||||
t.Setenv("LVMH_TEST_ENV_OR", " value ")
|
t.Setenv("LVMH_TEST_ENV_OR", " value ")
|
||||||
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "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) {
|
func TestSpawnerNewBadDockerHost(t *testing.T) {
|
||||||
t.Setenv("DOCKER_HOST", "http://")
|
t.Setenv("DOCKER_HOST", "http://")
|
||||||
store := openTestStore(t)
|
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")
|
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)
|
t.Fatalf("write file: %v", err)
|
||||||
}
|
}
|
||||||
sp.reposDir = file
|
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")
|
t.Fatal("cloneOrUpdate with file reposDir must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,7 +770,7 @@ func TestSpawnerEnsureImageErrors(t *testing.T) {
|
|||||||
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
|
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
|
||||||
useFakeGit(t, fakeGitModeOK)
|
useFakeGit(t, fakeGitModeOK)
|
||||||
f := newFakeDocker()
|
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
|
f.failVolumeCreate = true
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "")
|
||||||
@@ -590,12 +790,12 @@ func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
f.failCreate = true
|
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")
|
t.Fatal("seedVolume with failing create must fail")
|
||||||
}
|
}
|
||||||
f.failCreate = false
|
f.failCreate = false
|
||||||
f.failStart = true
|
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")
|
t.Fatal("seedVolume with failing start must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,7 +832,7 @@ func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
|
|||||||
useFakeGit(t, fakeGitModeFail)
|
useFakeGit(t, fakeGitModeFail)
|
||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, _ := newTestSpawner(t, f)
|
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 {
|
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir .git: %v", err)
|
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.
|
// worker container create/start instead of at the seed container.
|
||||||
useFakeGit(t, fakeGitModeOK)
|
useFakeGit(t, fakeGitModeOK)
|
||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
f.volume["lvmh-repo-group-project"] = true
|
f.volume["lvmh-repo-group--project"] = true
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
f.failCreate = true
|
f.failCreate = true
|
||||||
|
|||||||
+42
-1
@@ -131,6 +131,47 @@ func (s *Store) EventsAfter(sessionID string, after int64, limit int) ([]Event,
|
|||||||
return out, rows.Err()
|
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.
|
// UpsertSession inserts or refreshes the info snapshot for a session.
|
||||||
func (s *Store) UpsertSession(info SessionInfo) error {
|
func (s *Store) UpsertSession(info SessionInfo) error {
|
||||||
blob, err := json.Marshal(info)
|
blob, err := json.Marshal(info)
|
||||||
@@ -158,7 +199,7 @@ func (s *Store) SetOnline(sessionID string, online bool) error {
|
|||||||
return err
|
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) {
|
func (s *Store) Sessions() ([]SessionRow, error) {
|
||||||
rows, err := s.db.Query(`SELECT id, info, lastSeq, lastEventAt, online FROM sessions ORDER BY id`)
|
rows, err := s.db.Query(`SELECT id, info, lastSeq, lastEventAt, online FROM sessions ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ func TestStoreCorruptRows(t *testing.T) {
|
|||||||
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
|
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
|
||||||
t.Fatal("EventsAfter with text seq must scan-error")
|
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) {
|
func TestStoreAppendEventDefaultPayload(t *testing.T) {
|
||||||
@@ -100,3 +103,54 @@ func TestStoreAppendEventDefaultPayload(t *testing.T) {
|
|||||||
t.Fatalf("payload = %q, want {}", events[0].Payload)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -492,9 +492,8 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
// notice reflects every gap, not just cap drops.
|
// notice reflects every gap, not just cap drops.
|
||||||
const covered: number =
|
const covered: number =
|
||||||
replayBuf.filter((f) => f.seq <= lastSeq).length +
|
replayBuf.filter((f) => f.seq <= lastSeq).length +
|
||||||
sendQueue.filter(
|
sendQueue.filter((f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq)
|
||||||
(f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq,
|
.length;
|
||||||
).length;
|
|
||||||
if (covered > 0) {
|
if (covered > 0) {
|
||||||
droppedEvents += covered;
|
droppedEvents += covered;
|
||||||
log(
|
log(
|
||||||
|
|||||||
+6
-3
@@ -18,7 +18,10 @@ interface BoundaryState {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
|
export class ErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode },
|
||||||
|
BoundaryState
|
||||||
|
> {
|
||||||
state: BoundaryState = { error: null };
|
state: BoundaryState = { error: null };
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): BoundaryState {
|
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||||
@@ -102,6 +105,7 @@ export default function App() {
|
|||||||
const archived = store.sessions.filter((s) => !s.online).sort(byActivity);
|
const archived = store.sessions.filter((s) => !s.online).sort(byActivity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<nav
|
<nav
|
||||||
className={classNames("sidebar", sidebarOpen && "open")}
|
className={classNames("sidebar", sidebarOpen && "open")}
|
||||||
@@ -180,7 +184,6 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<ErrorBoundary>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -202,7 +205,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
|
||||||
</main>
|
</main>
|
||||||
<div className="toasts" role="status">
|
<div className="toasts" role="status">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
@@ -212,5 +214,6 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+451
-304
@@ -1,336 +1,483 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ChatMessage, ToolState } from "./derive";
|
import type { Repo, SessionListItem } from "./protocol";
|
||||||
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
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 {
|
return {
|
||||||
key: `k-${Math.random()}`,
|
sessions: [],
|
||||||
role: "assistant",
|
state: "open",
|
||||||
text: "",
|
spawnJobs: [],
|
||||||
thinking: null,
|
refresh: async (): Promise<SessionListItem[]> =>
|
||||||
toolCalls: [],
|
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
|
||||||
toolCallId: null,
|
subscribe: (): (() => void) => () => undefined,
|
||||||
streaming: false,
|
...over,
|
||||||
...partial,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
function tree(store: SessionsStore): React.ReactElement {
|
||||||
id: "c1",
|
return (
|
||||||
name: "bash",
|
<MemoryRouter initialEntries={["/new"]}>
|
||||||
args: "",
|
<Routes>
|
||||||
running: false,
|
<Route
|
||||||
isError: false,
|
path="/new"
|
||||||
preview: "",
|
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
|
||||||
...p,
|
/>
|
||||||
});
|
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
||||||
|
</Routes>
|
||||||
describe("Bubble", () => {
|
</MemoryRouter>
|
||||||
it("renders plain text per role class", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({ role: "user", text: "hi there" })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
}
|
||||||
expect(container.textContent).toContain("hi there");
|
|
||||||
|
/** 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", () => {
|
afterEach(() => {
|
||||||
const { container } = render(
|
vi.restoreAllMocks();
|
||||||
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("spawning view shows job line from status and spawn error text", async () => {
|
||||||
const long = `${"x".repeat(200)}`;
|
mockFetchJson((url, init) => {
|
||||||
render(
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
<Bubble
|
return { sessionId: "sx", containerId: "" };
|
||||||
msg={msg({ role: "toolResult", text: long })}
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
tools={new Map()}
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
/>,
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
);
|
return [];
|
||||||
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({
|
||||||
it("toolResult with short text keeps full one-line preview", () => {
|
spawnJobs: [
|
||||||
render(
|
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
|
||||||
<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(
|
render(tree(store));
|
||||||
<Bubble
|
await flush();
|
||||||
msg={msg({
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
role: "assistant",
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
text: "finished",
|
await flush();
|
||||||
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
})}
|
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||||
tools={tools}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const summary = screen
|
|
||||||
.getByText("🛠 bash")
|
|
||||||
.closest("summary") as HTMLElement;
|
|
||||||
const card = summary.closest("details") as HTMLDetailsElement;
|
|
||||||
expect(card.open).toBe(false);
|
|
||||||
await userEvent.click(summary);
|
|
||||||
expect(card.open).toBe(true);
|
|
||||||
expect(card.textContent).toContain("ls -la");
|
|
||||||
expect(card.textContent).toContain("file");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tool card status variants: running, error, done", () => {
|
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
||||||
const tools = new Map<string, ToolState>([
|
const bodies: string[] = [];
|
||||||
["c1", tool({ id: "c1", running: true })],
|
mockFetchJson((url, init) => {
|
||||||
["c2", tool({ id: "c2", running: false, isError: true })],
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
["c3", tool({ id: "c3", running: false, isError: false })],
|
bodies.push(String(init.body));
|
||||||
]);
|
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
||||||
render(
|
}
|
||||||
<Bubble
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
msg={msg({
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
role: "assistant",
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
toolCalls: ["c1", "c2", "c3"].map((id) => ({
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
||||||
id,
|
return [];
|
||||||
name: `t-${id}`,
|
});
|
||||||
argsJson: "{}",
|
const failingRefresh = makeStore({
|
||||||
})),
|
refresh: (): Promise<SessionListItem[]> =>
|
||||||
})}
|
Promise.reject(new Error("boom")),
|
||||||
tools={tools}
|
});
|
||||||
/>,
|
render(tree(failingRefresh));
|
||||||
);
|
await flush();
|
||||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
fireEvent.click(screen.getByText("g/blank"));
|
||||||
expect(screen.getByText("error")).toBeInTheDocument();
|
fireEvent.change(screen.getByLabelText("Branch"), {
|
||||||
expect(screen.getByText("done")).toBeInTheDocument();
|
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", () => {
|
it("spawn POST failure shows the error", async () => {
|
||||||
const { container } = render(
|
mockFetchJson((url, init) => {
|
||||||
<Bubble
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
msg={msg({
|
return jsonResponse({ error: "no docker" }, 500);
|
||||||
role: "assistant",
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
})}
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
||||||
tools={new Map()}
|
return [];
|
||||||
/>,
|
});
|
||||||
);
|
render(tree(makeStore()));
|
||||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
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 () => {
|
it("polling gives up after the tick cap and stays on the page", async () => {
|
||||||
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
let statusCalls = 0;
|
||||||
const details = screen
|
mockFetchJson((url) => {
|
||||||
.getByText("thinking")
|
if (url.endsWith("/api/spawn/status")) {
|
||||||
.closest("details") as HTMLDetailsElement;
|
statusCalls += 1;
|
||||||
await userEvent.click(screen.getByText("thinking"));
|
return [];
|
||||||
expect(details.open).toBe(true);
|
}
|
||||||
expect(details.textContent).toContain("because");
|
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(
|
await act(async () => {
|
||||||
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
vi.advanceTimersByTime(1500 * 402);
|
||||||
);
|
});
|
||||||
expect(container.querySelector(".thinking")).toBeNull();
|
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", () => {
|
it("spawn job line renders from store.spawnJobs", async () => {
|
||||||
const { container } = render(
|
mockFetchJson((url, init) => {
|
||||||
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
);
|
return { sessionId: "sj-1", containerId: "cid" };
|
||||||
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
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", () => {
|
describe("SpawnSteps", () => {
|
||||||
it("renders three dots with aria-live", () => {
|
it("renders progress steps matching job state", async () => {
|
||||||
const { container } = render(<TypingIndicator />);
|
mockFetchJson((url, init) => {
|
||||||
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
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("progress element carries progressbar semantics for the current step", async () => {
|
||||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
mockFetchJson((url, init) => {
|
||||||
const { container, rerender } = render(
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
<ChatStream
|
return { sessionId: "sp2", containerId: "" };
|
||||||
messages={[msg({ key: "a", text: "one" })]}
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
tools={new Map()}
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
busy
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
/>,
|
return [];
|
||||||
);
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
const store = makeStore({
|
||||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
spawnJobs: [
|
||||||
const { container, rerender } = render(
|
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
|
||||||
<ChatStream
|
],
|
||||||
messages={[msg({ key: "a" })]}
|
|
||||||
tools={new Map()}
|
|
||||||
busy={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
|
||||||
Object.defineProperty(scroller, "scrollHeight", {
|
|
||||||
configurable: true,
|
|
||||||
value: 1000,
|
|
||||||
});
|
});
|
||||||
Object.defineProperty(scroller, "clientHeight", {
|
render(tree(store));
|
||||||
configurable: true,
|
await flush();
|
||||||
value: 300,
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
});
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
const bar = screen.getByRole("progressbar");
|
||||||
// pinned: scrollTop at bottom
|
expect(bar).toHaveAttribute("aria-valuemin", "1");
|
||||||
scroller.scrollTop = 700;
|
expect(bar).toHaveAttribute("aria-valuemax", "4");
|
||||||
Object.defineProperty(scroller, "scrollTop", {
|
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-1
@@ -136,9 +136,20 @@ interface Props {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
tools: Map<string, ToolState>;
|
tools: Map<string, ToolState>;
|
||||||
busy: boolean;
|
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 scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pinnedRef = useRef<boolean>(true);
|
const pinnedRef = useRef<boolean>(true);
|
||||||
|
|
||||||
@@ -166,6 +177,19 @@ export default function ChatStream({ messages, tools, busy }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
|
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||||
<div className="chat-inner">
|
<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) => (
|
{messages.map((m) => (
|
||||||
<Bubble key={m.key} msg={m} tools={tools} />
|
<Bubble key={m.key} msg={m} tools={tools} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+336
-6
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ReactElement } from "react";
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { EventFrame } from "./protocol";
|
import type { EventFrame } from "./protocol";
|
||||||
import { ApiError } from "./api";
|
import { ApiError } from "./api";
|
||||||
@@ -42,7 +42,7 @@ function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
|||||||
sessions,
|
sessions,
|
||||||
state: "open",
|
state: "open",
|
||||||
spawnJobs: [],
|
spawnJobs: [],
|
||||||
refresh: async () => undefined,
|
refresh: async () => [],
|
||||||
subscribe: (
|
subscribe: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onEvents: (events: EventFrame[]) => void,
|
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(() => {
|
beforeEach(() => {
|
||||||
seq = 0;
|
seq = 0;
|
||||||
currentSub = null;
|
currentSub = null;
|
||||||
@@ -362,7 +389,8 @@ describe("ChatView", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
await screen.findByText("caught up");
|
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
|
// reconnect: state closed -> open triggers the after=N refetch
|
||||||
act(() => {
|
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", () => {
|
describe("ChatView close pi", () => {
|
||||||
it("close button deletes the container, toasts and refreshes (agent session)", async () => {
|
it("close button deletes the container, toasts and refreshes (agent session)", async () => {
|
||||||
const agent = [{ ...sessions[0]!, id: "s1", agent: true }];
|
const agent = [{ ...sessions[0]!, id: "s1", agent: true }];
|
||||||
const refresh = vi.fn(async () => undefined);
|
const refresh = vi.fn(async () => []);
|
||||||
const deletes: string[] = [];
|
const deletes: string[] = [];
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "DELETE" && url.includes("/container")) {
|
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", () => {
|
describe("ChatView unknown session", () => {
|
||||||
it("unknown id falls back to id title and hides agent-only controls", async () => {
|
it("unknown id falls back to id title and hides agent-only controls", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
@@ -505,7 +832,9 @@ describe("ChatView usage chip", () => {
|
|||||||
seq = 0;
|
seq = 0;
|
||||||
return [
|
return [
|
||||||
...historyEvents(),
|
...historyEvents(),
|
||||||
ev("agent_end", { usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 } }),
|
ev("agent_end", {
|
||||||
|
usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 },
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -518,7 +847,8 @@ describe("ChatView usage chip", () => {
|
|||||||
|
|
||||||
it("usage chip hidden when no usage seen", async () => {
|
it("usage chip hidden when no usage seen", async () => {
|
||||||
mockFetchJson((url) => {
|
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 [];
|
return [];
|
||||||
});
|
});
|
||||||
renderChat(makeStore());
|
renderChat(makeStore());
|
||||||
|
|||||||
+53
-7
@@ -15,6 +15,7 @@ import ChatStream from "./ChatStream";
|
|||||||
import TaskPanel from "./TaskPanel";
|
import TaskPanel from "./TaskPanel";
|
||||||
|
|
||||||
const HISTORY_LIMIT: number = 1000;
|
const HISTORY_LIMIT: number = 1000;
|
||||||
|
const LATEST_QUERY: string = "latest=1";
|
||||||
const TEXTAREA_MAX_H: number = 200;
|
const TEXTAREA_MAX_H: number = 200;
|
||||||
const SEND_KEY: string = "Enter";
|
const SEND_KEY: string = "Enter";
|
||||||
|
|
||||||
@@ -32,10 +33,15 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
const [draft, setDraft] = useState<string>("");
|
const [draft, setDraft] = useState<string>("");
|
||||||
const [sending, setSending] = useState<boolean>(false);
|
const [sending, setSending] = useState<boolean>(false);
|
||||||
const [tasksOpen, setTasksOpen] = 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 lastSeqRef = useRef<number>(0);
|
||||||
const loadedRef = useRef<boolean>(false);
|
const loadedRef = useRef<boolean>(false);
|
||||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (sessionId.length === 0) return;
|
if (sessionId.length === 0) return;
|
||||||
loadedRef.current = false;
|
loadedRef.current = false;
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
|
setHasOlder(false);
|
||||||
lastSeqRef.current = 0;
|
lastSeqRef.current = 0;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const evts = await fetchJson<EventFrame[]>(
|
const evts = await fetchJson<EventFrame[]>(
|
||||||
`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
|
`${Route.SessionEvents(sessionId)}?${LATEST_QUERY}&limit=${HISTORY_LIMIT}`,
|
||||||
);
|
);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
applyEvents(evts);
|
applyEvents(evts);
|
||||||
|
setHasOlder(evts.length >= HISTORY_LIMIT);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (alive) setLoadError(errMessage(err));
|
if (alive) setLoadError(errMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -94,20 +102,52 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
return store.subscribe(sessionId, applyEvents);
|
return store.subscribe(sessionId, applyEvents);
|
||||||
}, [sessionId, store.state, store, 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(() => {
|
useEffect(() => {
|
||||||
if (store.state !== "open" || !loadedRef.current) return;
|
if (store.state !== "open" || !loadedRef.current) return;
|
||||||
|
const id: string = sessionId;
|
||||||
const after: number = lastSeqRef.current;
|
const after: number = lastSeqRef.current;
|
||||||
void fetchJson<EventFrame[]>(
|
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);
|
.catch(() => undefined);
|
||||||
}, [store.state, sessionId, applyEvents]);
|
}, [store.state, sessionId, applyEvents]);
|
||||||
|
|
||||||
const chat = useMemo(() => deriveChat(events), [events]);
|
const chat = useMemo(() => deriveChat(events), [events]);
|
||||||
const tasks = useMemo(() => deriveTasks(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 autosize = useCallback((): void => {
|
||||||
const el = taRef.current;
|
const el = taRef.current;
|
||||||
if (el === null) return;
|
if (el === null) return;
|
||||||
@@ -128,6 +168,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
body: JSON.stringify({ message: text }),
|
body: JSON.stringify({ message: text }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// the message never reached the session: put it back (S6)
|
||||||
|
setDraft(text);
|
||||||
if (err instanceof ApiError && err.status === 409)
|
if (err instanceof ApiError && err.status === 409)
|
||||||
pushToast("session offline");
|
pushToast("session offline");
|
||||||
else pushToast(errMessage(err));
|
else pushToast(errMessage(err));
|
||||||
@@ -215,9 +257,13 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChatStream
|
<ChatStream
|
||||||
|
key={sessionId}
|
||||||
messages={chat.messages}
|
messages={chat.messages}
|
||||||
tools={chat.tools}
|
tools={chat.tools}
|
||||||
busy={chat.busy}
|
busy={busy}
|
||||||
|
hasOlder={hasOlder}
|
||||||
|
loadingOlder={loadingOlder}
|
||||||
|
onLoadOlder={() => void loadOlder()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
@@ -231,7 +277,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
{chat.busy ? (
|
{busy ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="abort-btn"
|
className="abort-btn"
|
||||||
|
|||||||
@@ -61,20 +61,22 @@ describe("SessionsView", () => {
|
|||||||
it("empty state message (after load window)", () => {
|
it("empty state message (after load window)", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
renderView({ sessions: [] });
|
renderView({ sessions: [] });
|
||||||
act(() => { vi.advanceTimersByTime(700); });
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
});
|
||||||
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
|
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skeleton while first load pending", () => {
|
it("skeleton while first load pending", () => {
|
||||||
renderView({ sessions: [] });
|
renderView({ sessions: [] });
|
||||||
expect(screen.getByRole("heading", { name: "Active sessions" })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Active sessions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
const skel = document.querySelector(".skeleton");
|
const skel = document.querySelector(".skeleton");
|
||||||
expect(skel).not.toBeNull();
|
expect(skel).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it("renders cards sorted by last activity with fallbacks", () => {
|
it("renders cards sorted by last activity with fallbacks", () => {
|
||||||
renderView({
|
renderView({
|
||||||
sessions: [
|
sessions: [
|
||||||
@@ -181,12 +183,54 @@ describe("SessionsView", () => {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
|
const ev = fireEvent.keyDown(
|
||||||
|
screen.getByRole("button", { name: "Open session s8" }),
|
||||||
|
{
|
||||||
key: " ",
|
key: " ",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
expect(ev).toBe(false); // preventDefault consumed the default action
|
||||||
expect(probe).toHaveBeenCalledWith("/s/s8");
|
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 () => {
|
it("stop with unnamed session toasts the id", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => ({ ok: true }));
|
mockFetchJson(() => ({ ok: true }));
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ function SessionCard({
|
|||||||
aria-label={`Open session ${s.name ?? s.id}`}
|
aria-label={`Open session ${s.name ?? s.id}`}
|
||||||
onClick={() => onOpen(s.id)}
|
onClick={() => onOpen(s.id)}
|
||||||
onKeyDown={(e) => {
|
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
|
<span
|
||||||
|
|||||||
+365
-413
@@ -1,448 +1,400 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Repo } from "./protocol";
|
import type { ChatMessage, ToolState } from "./derive";
|
||||||
import SpawnView from "./SpawnView";
|
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||||
import type { SessionsStore } from "./store";
|
|
||||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
|
||||||
|
|
||||||
const repo = (path: string, branch = "main"): Repo => ({
|
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||||
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 {
|
return {
|
||||||
sessions: [],
|
key: `k-${Math.random()}`,
|
||||||
state: "open",
|
role: "assistant",
|
||||||
spawnJobs: [],
|
text: "",
|
||||||
refresh: async (): Promise<void> => undefined,
|
thinking: null,
|
||||||
subscribe: (): (() => void) => () => undefined,
|
toolCalls: [],
|
||||||
...over,
|
toolCallId: null,
|
||||||
|
streaming: false,
|
||||||
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tree(store: SessionsStore): React.ReactElement {
|
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||||
return (
|
id: "c1",
|
||||||
<MemoryRouter initialEntries={["/new"]}>
|
name: "bash",
|
||||||
<Routes>
|
args: "",
|
||||||
<Route
|
running: false,
|
||||||
path="/new"
|
isError: false,
|
||||||
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
|
preview: "",
|
||||||
/>
|
...p,
|
||||||
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
});
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
describe("Bubble", () => {
|
||||||
|
it("renders plain text per role class", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({ role: "user", text: "hi there" })}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
}
|
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("hi there");
|
||||||
/** Flush pending microtasks + React effects (works under fake timers). */
|
|
||||||
async function flush(ticks = 4): Promise<void> {
|
|
||||||
for (let i = 0; i < ticks; i += 1) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
PUSH_TOAST.mockClear();
|
|
||||||
seedSettings();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||||
vi.restoreAllMocks();
|
const { container } = render(
|
||||||
});
|
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
||||||
|
|
||||||
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" }),
|
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawning view shows job line from status and spawn error text", async () => {
|
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||||
mockFetchJson((url, init) => {
|
const long = `${"x".repeat(200)}`;
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
render(
|
||||||
return { sessionId: "sx", containerId: "" };
|
<Bubble
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
msg={msg({ role: "toolResult", text: long })}
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
tools={new Map()}
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
/>,
|
||||||
return [];
|
);
|
||||||
|
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: [
|
it("toolResult with short text keeps full one-line preview", () => {
|
||||||
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
|
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));
|
render(
|
||||||
await flush();
|
<Bubble
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
msg={msg({
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
role: "assistant",
|
||||||
await flush();
|
text: "finished",
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
})}
|
||||||
|
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 () => {
|
it("tool card status variants: running, error, done", () => {
|
||||||
const bodies: string[] = [];
|
const tools = new Map<string, ToolState>([
|
||||||
mockFetchJson((url, init) => {
|
["c1", tool({ id: "c1", running: true })],
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
["c2", tool({ id: "c2", running: false, isError: true })],
|
||||||
bodies.push(String(init.body));
|
["c3", tool({ id: "c3", running: false, isError: false })],
|
||||||
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
]);
|
||||||
|
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 () => {
|
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||||
vi.advanceTimersByTime(1500);
|
const { container, rerender } = render(
|
||||||
});
|
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
|
||||||
await flush();
|
);
|
||||||
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
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 () => {
|
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||||
mockFetchJson((url, init) => {
|
const { container, rerender } = render(
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||||
return jsonResponse({ error: "no docker" }, 500);
|
);
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
Object.defineProperty(scroller, "scrollHeight", {
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
configurable: true,
|
||||||
return [];
|
value: 1000,
|
||||||
});
|
});
|
||||||
render(tree(makeStore()));
|
Object.defineProperty(scroller, "clientHeight", {
|
||||||
await flush();
|
configurable: true,
|
||||||
fireEvent.click(screen.getByText("g/x"));
|
value: 300,
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
expect(screen.getByText("no docker")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("polling gives up after the tick cap and stays on the page", async () => {
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
let statusCalls = 0;
|
// pinned: scrollTop at bottom
|
||||||
mockFetchJson((url) => {
|
scroller.scrollTop = 700;
|
||||||
if (url.endsWith("/api/spawn/status")) {
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
statusCalls += 1;
|
configurable: true,
|
||||||
return [];
|
writable: true,
|
||||||
}
|
value: 700,
|
||||||
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()));
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
await flush();
|
rerender(
|
||||||
fireEvent.click(screen.getByText("g/slow"));
|
stream({
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
messages: [msg({ key: "a" }), msg({ key: "b" })],
|
||||||
await flush();
|
busy: false,
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
}),
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(1000);
|
||||||
|
|
||||||
await act(async () => {
|
// scroll far up -> unpin
|
||||||
vi.advanceTimersByTime(1500 * 402);
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: 0,
|
||||||
});
|
});
|
||||||
await flush();
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
const afterCap = statusCalls;
|
rerender(
|
||||||
await act(async () => {
|
stream({
|
||||||
vi.advanceTimersByTime(1500 * 10);
|
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();
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
expect(statusCalls).toBe(afterCap);
|
rerender(
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
stream({
|
||||||
unmount();
|
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 () => {
|
it("load-older button renders only when an older page exists and fires on click", () => {
|
||||||
mockFetchJson((url, init) => {
|
const onOlder = vi.fn();
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
const { rerender } = render(
|
||||||
return { sessionId: "sj-1", containerId: "cid" };
|
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
);
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
expect(
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
screen.queryByRole("button", { name: "Load older messages" }),
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
).toBeNull();
|
||||||
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" }];
|
rerender(
|
||||||
act(() => {
|
stream({
|
||||||
rerender(tree(store));
|
messages: [msg({ key: "a" })],
|
||||||
});
|
busy: false,
|
||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
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", () => {
|
describe("copy button", () => {
|
||||||
it("renders progress steps matching job state", async () => {
|
it("copy button writes message text and flashes copied", async () => {
|
||||||
mockFetchJson((url, init) => {
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
Object.assign(navigator, { clipboard: { writeText } });
|
||||||
return { sessionId: "sp1", containerId: "" };
|
render(
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
<Bubble
|
||||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
msg={{
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
key: "k",
|
||||||
return [];
|
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: "" }],
|
it("user bubbles get a copy button, toolResults do not", () => {
|
||||||
});
|
const { rerender } = render(
|
||||||
render(tree(store));
|
<Bubble
|
||||||
await flush();
|
msg={{
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
key: "u",
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
role: "user",
|
||||||
await flush();
|
text: "hi",
|
||||||
const steps = document.querySelectorAll(".spawn-progress .step");
|
thinking: null,
|
||||||
expect(steps).toHaveLength(4);
|
toolCalls: [],
|
||||||
expect(steps[0]?.className).toContain("done");
|
toolCallId: null,
|
||||||
expect(steps[1]?.className).toContain("current");
|
streaming: false,
|
||||||
expect(steps[2]?.className).toBe("step");
|
}}
|
||||||
|
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
@@ -1,12 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type {
|
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
|
||||||
GitlabStatus,
|
|
||||||
Repo,
|
|
||||||
SessionListItem,
|
|
||||||
SpawnJob,
|
|
||||||
SpawnResponse,
|
|
||||||
} from "./protocol";
|
|
||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
import { errMessage, fetchJson } from "./api";
|
import { errMessage, fetchJson } from "./api";
|
||||||
import type { SessionsStore } from "./store";
|
import type { SessionsStore } from "./store";
|
||||||
@@ -19,14 +13,21 @@ interface Props {
|
|||||||
pushToast: (text: string) => void;
|
pushToast: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"];
|
const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"];
|
||||||
|
|
||||||
function SpawnSteps({ state }: { state: string }): React.ReactNode {
|
function SpawnSteps({ state }: { state: string }): React.ReactNode {
|
||||||
const idx = SPAWN_STEPS.indexOf(state);
|
const idx = SPAWN_STEPS.indexOf(state);
|
||||||
if (state === "error") return null;
|
if (state === "error") return null;
|
||||||
|
const currentStepIndex: number = idx >= 0 ? idx : 0;
|
||||||
return (
|
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) => (
|
{SPAWN_STEPS.map((step, i) => (
|
||||||
<span
|
<span
|
||||||
key={step}
|
key={step}
|
||||||
@@ -151,9 +152,8 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
|
const list = await store.refresh();
|
||||||
const s = list.find((x) => x.id === sessionId);
|
const s = list.find((x) => x.id === sessionId);
|
||||||
await store.refresh();
|
|
||||||
if (s !== undefined && s.online) {
|
if (s !== undefined && s.online) {
|
||||||
if (timerRef.current !== null)
|
if (timerRef.current !== null)
|
||||||
window.clearInterval(timerRef.current);
|
window.clearInterval(timerRef.current);
|
||||||
|
|||||||
@@ -475,6 +475,15 @@ a:hover {
|
|||||||
max-width: 780px;
|
max-width: 780px;
|
||||||
margin: 0 auto;
|
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 {
|
.bubble-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -523,6 +532,10 @@ a:hover {
|
|||||||
.bubble-row:hover .msg-copy {
|
.bubble-row:hover .msg-copy {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.bubble-row .msg-copy:focus-visible,
|
||||||
|
.bubble-row:focus-within .msg-copy {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.bubble-row.user .msg-copy {
|
.bubble-row.user .msg-copy {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: -4px;
|
left: -4px;
|
||||||
|
|||||||
+189
-4
@@ -4,11 +4,12 @@ import { getSettings, saveSettings } from "./settings";
|
|||||||
import { classNames, relativeTime, useSessions, useToasts } from "./store";
|
import { classNames, relativeTime, useSessions, useToasts } from "./store";
|
||||||
import {
|
import {
|
||||||
FakeWebSocket,
|
FakeWebSocket,
|
||||||
|
jsonResponse,
|
||||||
mockFetchJson,
|
mockFetchJson,
|
||||||
seedSettings,
|
seedSettings,
|
||||||
stubReload,
|
stubReload,
|
||||||
} from "./test/setup";
|
} from "./test/setup";
|
||||||
import type { EventFrame } from "./protocol";
|
import type { EventFrame, SessionListItem } from "./protocol";
|
||||||
|
|
||||||
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
|
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 () => {
|
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
const sessions = [{ id: "s1", name: "one", online: true }];
|
const sessions: SessionListItem[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
name: "one",
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
] as SessionListItem[];
|
||||||
const fetchMock = mockFetchJson((url) => {
|
const fetchMock = mockFetchJson((url) => {
|
||||||
if (url.includes("/api/sessions"))
|
if (url.includes("/api/sessions"))
|
||||||
return url.includes("/events") ? [] : sessions;
|
return url.includes("/events") ? [] : sessions;
|
||||||
@@ -108,13 +115,17 @@ describe("useSessions", () => {
|
|||||||
{ repo: "g/p", state: "cloning" },
|
{ 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 act(async () => {
|
||||||
await result.current?.refresh();
|
out = await result.current?.refresh();
|
||||||
});
|
});
|
||||||
|
expect(out).toEqual(sessions);
|
||||||
|
expect(result.current?.sessions).toEqual(sessions);
|
||||||
expect(fetchMock).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refresh failure pushes a toast", async () => {
|
it("refresh failure pushes a toast and resolves to an empty list", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => {
|
mockFetchJson(() => {
|
||||||
throw new Error("network down");
|
throw new Error("network down");
|
||||||
@@ -125,6 +136,11 @@ describe("useSessions", () => {
|
|||||||
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
||||||
);
|
);
|
||||||
expect(result.current).not.toBeNull();
|
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 () => {
|
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
|
||||||
@@ -192,6 +208,130 @@ describe("useSessions", () => {
|
|||||||
loc.restore();
|
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 () => {
|
it("unmount closes the manager", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
@@ -203,6 +343,51 @@ describe("useSessions", () => {
|
|||||||
expect(sock.closeCode).toBe(4900);
|
expect(sock.closeCode).toBe(4900);
|
||||||
expect(sock.onclose).toBeNull();
|
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", () => {
|
describe("settings persistence used by the store", () => {
|
||||||
|
|||||||
+45
-9
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
EventFrame,
|
EventFrame,
|
||||||
ServerFrame,
|
ServerFrame,
|
||||||
@@ -8,13 +8,15 @@ import type {
|
|||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
import { buildWsUrl, getSettings, clearSettings } from "./settings";
|
import { buildWsUrl, getSettings, clearSettings } from "./settings";
|
||||||
import { createWsManager, type WsManager, type WsState } from "./ws";
|
import { createWsManager, type WsManager, type WsState } from "./ws";
|
||||||
import { errMessage, fetchJson } from "./api";
|
import { ApiError, errMessage, fetchJson } from "./api";
|
||||||
|
|
||||||
// ---------- small shared helpers ----------
|
// ---------- small shared helpers ----------
|
||||||
|
|
||||||
const MINUTE_MS: number = 60_000;
|
const MINUTE_MS: number = 60_000;
|
||||||
const HOUR_MS: number = 60 * MINUTE_MS;
|
const HOUR_MS: number = 60 * MINUTE_MS;
|
||||||
const DAY_MS: number = 24 * HOUR_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 {
|
export function relativeTime(ts: number | null): string {
|
||||||
if (ts === null) return "never";
|
if (ts === null) return "never";
|
||||||
@@ -60,7 +62,8 @@ export interface SessionsStore {
|
|||||||
sessions: SessionListItem[];
|
sessions: SessionListItem[];
|
||||||
state: WsState;
|
state: WsState;
|
||||||
spawnJobs: SpawnJob[];
|
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 to one session's event stream (protocol allows one at a time). */
|
||||||
subscribe: (
|
subscribe: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -88,21 +91,46 @@ export function useSessions(
|
|||||||
if (frame.type === "session_list") setSessions(frame.sessions);
|
if (frame.type === "session_list") setSessions(frame.sessions);
|
||||||
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
|
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
|
||||||
});
|
});
|
||||||
m.onAuthError(() => {
|
const handleAuthError = (): void => {
|
||||||
clearSettings();
|
clearSettings();
|
||||||
window.location.reload();
|
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 {
|
try {
|
||||||
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
|
||||||
|
setSessions(list);
|
||||||
|
return list;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast(`sessions: ${errMessage(err)}`);
|
pushToast(`sessions: ${errMessage(err)}`);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
offFail();
|
||||||
offState();
|
offState();
|
||||||
offFrames();
|
offFrames();
|
||||||
m.close();
|
m.close();
|
||||||
@@ -110,11 +138,14 @@ export function useSessions(
|
|||||||
};
|
};
|
||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
|
||||||
try {
|
try {
|
||||||
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
|
||||||
|
setSessions(list);
|
||||||
|
return list;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast(`sessions: ${errMessage(err)}`);
|
pushToast(`sessions: ${errMessage(err)}`);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
@@ -137,9 +168,14 @@ export function useSessions(
|
|||||||
[manager],
|
[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
|
// 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
|
// mid-flight (ws auth failure) must not change the hook order on re-render
|
||||||
if (getSettings() === null) return null;
|
if (getSettings() === null) return null;
|
||||||
|
|
||||||
return { sessions, state, spawnJobs, refresh, subscribe };
|
return store;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user