fix(review round 2): daemon symlink tars, orphan-safe removes, unique seed names, git ctx, IsErrNotFound, collision-free slugs, branch validation; web first-run WS, draft reset, IME guard, churn fix, test swap repair; 106 daemon + 190 web tests green
This commit is contained in:
+4
-3
@@ -173,9 +173,10 @@ Errors: `{"error": "message"}` with appropriate status.
|
|||||||
|
|
||||||
`POST /api/spawn` semantics:
|
`POST /api/spawn` semantics:
|
||||||
|
|
||||||
1. Clone/pull repo into volume `lvmh-repo-<slug>` (slug = repo path with `/`
|
1. Clone/pull repo into volume `lvmh-repo-<slug>` (slug = repo path with
|
||||||
→ `-`), default branch unless `branch` given. Concurrent spawns on same
|
`/` → `--`, plus `-` + first 6 hex of sha256(repo path) so distinct
|
||||||
volume serialize.
|
paths can never collide), default branch unless `branch` given.
|
||||||
|
Concurrent spawns on same volume serialize.
|
||||||
2. Create container from image `lvmh-worker:latest` (build from repo's
|
2. Create container from image `lvmh-worker:latest` (build from repo's
|
||||||
`docker/worker.Dockerfile` if missing), env: `ZAI_RENAUD_API_KEY`,
|
`docker/worker.Dockerfile` if missing), env: `ZAI_RENAUD_API_KEY`,
|
||||||
`LVMH_TOKEN`, `LVMH_URL`, provider/models.json mounted read-only,
|
`LVMH_TOKEN`, `LVMH_URL`, provider/models.json mounted read-only,
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "repo must look like group/project")
|
writeError(w, http.StatusBadRequest, "repo must look like group/project")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.Branch != "" && !branchRe.MatchString(body.Branch) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid branch name")
|
||||||
|
return
|
||||||
|
}
|
||||||
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch)
|
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
+26
-9
@@ -106,16 +106,33 @@ func TestAPIEventsReplayShape(t *testing.T) {
|
|||||||
|
|
||||||
func TestAPISpawnValidation(t *testing.T) {
|
func TestAPISpawnValidation(t *testing.T) {
|
||||||
ts, _ := newTestServer(t)
|
ts, _ := newTestServer(t)
|
||||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/spawn",
|
post := func(body string) int {
|
||||||
strings.NewReader(`{"repo":"no-slash"}`))
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/spawn", strings.NewReader(body))
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("spawn: %v", err)
|
t.Fatalf("spawn: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
if code := post(`{"repo":"no-slash"}`); code != http.StatusBadRequest {
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
t.Fatalf("bad repo → %d, want 400", code)
|
||||||
t.Fatalf("bad repo → %d, want 400", resp.StatusCode)
|
}
|
||||||
|
// branch is passed to git: anything outside the safe charset → 400
|
||||||
|
for _, branch := range []string{
|
||||||
|
"main; rm -rf /",
|
||||||
|
"feature one", // space
|
||||||
|
"-oProxyCommand=x", // leading option-ish
|
||||||
|
"main$(id)",
|
||||||
|
} {
|
||||||
|
if code := post(`{"repo":"group/project","branch":"` + branch + `"}`); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("branch %q → %d, want 400", branch, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// empty branch (default) stays accepted at this validation layer
|
||||||
|
if code := post(`{"repo":"group/project","branch":""}`); code == http.StatusBadRequest {
|
||||||
|
t.Fatal("empty branch must not be rejected as invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-22
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/build"
|
"github.com/docker/docker/api/types/build"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
@@ -69,10 +71,20 @@ var errNoContainer = errors.New("no container for session")
|
|||||||
|
|
||||||
var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
|
var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
|
||||||
|
|
||||||
|
// branchRe guards spawn branch names against git option injection.
|
||||||
|
var branchRe = regexp.MustCompile(`^[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, "/", "--") }
|
// repoSlug maps a repo path to a collision-free filesystem/volume name:
|
||||||
|
// the `/`→`--` form alone is ambiguous ("a/b--c" vs "a/b/c"), so the
|
||||||
|
// first 6 hex chars of the sha256 of the full path disambiguate. Changing
|
||||||
|
// this changes existing volume names (one-time fresh clone per repo).
|
||||||
|
func repoSlug(repo string) string {
|
||||||
|
sum := sha256.Sum256([]byte(repo))
|
||||||
|
return strings.ReplaceAll(repo, "/", "--") + "-" + hex.EncodeToString(sum[:3])
|
||||||
|
}
|
||||||
|
|
||||||
// newUUID returns a random RFC 4122 v4 UUID string.
|
// newUUID returns a random RFC 4122 v4 UUID string.
|
||||||
func newUUID() string {
|
func newUUID() string {
|
||||||
@@ -214,11 +226,11 @@ func (s *Spawner) deleteJob(sessionID string) {
|
|||||||
|
|
||||||
// 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 {
|
exists, err := s.imageExists(ctx)
|
||||||
|
if err != nil {
|
||||||
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
||||||
}
|
}
|
||||||
exists, err := s.imageExists(ctx)
|
if !exists {
|
||||||
if err == nil && !exists {
|
|
||||||
if _, statErr := os.Stat(s.dockerfile); statErr != nil {
|
if _, statErr := os.Stat(s.dockerfile); statErr != nil {
|
||||||
return SpawnResult{}, fmt.Errorf(
|
return SpawnResult{}, fmt.Errorf(
|
||||||
"image %s not found and no worker Dockerfile at %s (set %s or run `make worker-image`)",
|
"image %s not found and no worker Dockerfile at %s (set %s or run `make worker-image`)",
|
||||||
@@ -258,7 +270,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
|||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
|
|
||||||
if err := s.cloneOrUpdate(repo, branch, slug); err != nil {
|
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
||||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,7 +295,7 @@ 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
|
// Credentials never appear in the clone URL (which git persists into
|
||||||
// .git/config); auth is passed per-invocation via http.extraHeader.
|
// .git/config); auth is passed per-invocation via http.extraHeader.
|
||||||
func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
func (s *Spawner) cloneOrUpdate(ctx context.Context, 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,15 +303,15 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
|||||||
}
|
}
|
||||||
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() {
|
||||||
auth := s.gitAuthArgs()
|
auth := s.gitAuthArgs()
|
||||||
if branch != "" && gitBranch(dir) != branch {
|
if branch != "" && gitBranch(ctx, dir) != branch {
|
||||||
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
|
if err := gitRun(ctx, dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
|
||||||
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
|
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
|
||||||
}
|
}
|
||||||
if err := gitRun(dir, "checkout", branch); err != nil {
|
if err := gitRun(ctx, dir, "checkout", branch); err != nil {
|
||||||
return fmt.Errorf("git checkout %s: %w", repo, err)
|
return fmt.Errorf("git checkout %s: %w", repo, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := gitRun(dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
|
if err := gitRun(ctx, 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
|
||||||
@@ -312,7 +324,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
|||||||
args = append(args, "--branch", branch)
|
args = append(args, "--branch", branch)
|
||||||
}
|
}
|
||||||
args = append(args, "--", cloneURL, dir)
|
args = append(args, "--", cloneURL, dir)
|
||||||
if err := gitRun("", args...); err != nil {
|
if err := gitRun(ctx, "", args...); err != nil {
|
||||||
return fmt.Errorf("git clone %s: %w", repo, err)
|
return fmt.Errorf("git clone %s: %w", repo, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -338,16 +350,16 @@ func (s *Spawner) gitAuthArgs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
|
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
|
||||||
func gitBranch(dir string) string {
|
func gitBranch(ctx context.Context, dir string) string {
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitRun(dir string, args ...string) error {
|
func gitRun(ctx context.Context, dir string, args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
}
|
}
|
||||||
@@ -445,7 +457,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if fresh {
|
if fresh {
|
||||||
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
|
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
|
||||||
// drop the half-seeded volume so the next spawn retries fresh
|
// drop the half-seeded volume so the next spawn retries fresh
|
||||||
// instead of silently booting into an empty workspace.
|
// instead of silently booting into an empty workspace.
|
||||||
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
|
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
|
||||||
@@ -497,9 +509,14 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensureRepoVolume creates the per-repo volume; reports whether it is fresh.
|
// ensureRepoVolume creates the per-repo volume; reports whether it is fresh.
|
||||||
|
// Only a definitive "no such volume" (docker errdefs NotFound) counts as
|
||||||
|
// fresh: a transient inspect error must never re-seed over an existing
|
||||||
|
// volume, so it propagates instead.
|
||||||
func (s *Spawner) ensureRepoVolume(ctx context.Context, name string) (bool, error) {
|
func (s *Spawner) ensureRepoVolume(ctx context.Context, name string) (bool, error) {
|
||||||
if _, err := s.cli.VolumeInspect(ctx, name); err == nil {
|
if _, err := s.cli.VolumeInspect(ctx, name); err == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
} else if !cerrdefs.IsNotFound(err) {
|
||||||
|
return false, fmt.Errorf("volume %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name}); err != nil {
|
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name}); err != nil {
|
||||||
return false, fmt.Errorf("volume %s: %w", name, err)
|
return false, fmt.Errorf("volume %s: %w", name, err)
|
||||||
@@ -511,7 +528,7 @@ func (s *Spawner) ensureRepoVolume(ctx context.Context, name string) (bool, erro
|
|||||||
// streaming a tar of the clone into a paused container via the docker API
|
// streaming a tar of the clone into a paused container via the docker API
|
||||||
// (CopyToContainer). No bind mounts: bind sources are HOST paths, but the
|
// (CopyToContainer). No bind mounts: bind sources are HOST paths, but the
|
||||||
// clone lives inside the daemon container's filesystem.
|
// clone lives inside the daemon container's filesystem.
|
||||||
func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error {
|
func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume, sessionID string) error {
|
||||||
repoDir := filepath.Join(s.reposDir, slug)
|
repoDir := filepath.Join(s.reposDir, slug)
|
||||||
cfg := &container.Config{
|
cfg := &container.Config{
|
||||||
Image: imageRefWorker,
|
Image: imageRefWorker,
|
||||||
@@ -521,7 +538,14 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
|
|||||||
Binds: []string{repoVolume + ":" + workspaceMount},
|
Binds: []string{repoVolume + ":" + workspaceMount},
|
||||||
Init: &[]bool{true}[0],
|
Init: &[]bool{true}[0],
|
||||||
}
|
}
|
||||||
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
|
// Unique name per session: a fixed name would conflict forever after a
|
||||||
|
// crash between create and the deferred remove.
|
||||||
|
unq := strings.ReplaceAll(sessionID, "-", "")
|
||||||
|
if len(unq) > 8 {
|
||||||
|
unq = unq[:8]
|
||||||
|
}
|
||||||
|
name := "lvmh-seed-" + slug + "-" + unq
|
||||||
|
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -554,7 +578,9 @@ func (s *Spawner) removeContainer(ctx context.Context, id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSession stops and removes the container spawned for a session.
|
// RemoveSession stops and removes the container spawned for a session.
|
||||||
func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
|
// Stop and remove run on background contexts on purpose: a caller hanging up
|
||||||
|
// mid-request must not leave an orphaned container behind a deleted DB row.
|
||||||
|
func (s *Spawner) RemoveSession(_ context.Context, sessionID string) error {
|
||||||
row, ok, err := s.store.GetContainer(sessionID)
|
row, ok, err := s.store.GetContainer(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -568,7 +594,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
|
|||||||
return fmt.Errorf("docker stop: %w", err)
|
return fmt.Errorf("docker stop: %w", err)
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
s.removeContainer(ctx, row.ContainerID)
|
s.removeContainer(context.Background(), row.ContainerID)
|
||||||
if err := s.store.DeleteContainer(sessionID); err != nil {
|
if err := s.store.DeleteContainer(sessionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -577,7 +603,9 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tarDir writes a tar archive of dir (excluding .git-heavy junk) into w.
|
// tarDir writes a tar archive of dir (nothing excluded, .git included) into w.
|
||||||
|
// Symlinks are archived as symlinks with their target in Linkname so docker's
|
||||||
|
// untar can recreate them; only regular files carry content.
|
||||||
func tarDir(w io.Writer, dir string) error {
|
func tarDir(w io.Writer, dir string) error {
|
||||||
tw := tar.NewWriter(w)
|
tw := tar.NewWriter(w)
|
||||||
defer tw.Close()
|
defer tw.Close()
|
||||||
@@ -593,7 +621,14 @@ func tarDir(w io.Writer, dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
name := filepath.ToSlash(rel)
|
name := filepath.ToSlash(rel)
|
||||||
hdr, err := tar.FileInfoHeader(info, "")
|
link := ""
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
link, err = os.Readlink(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-12
@@ -53,17 +53,18 @@ type fakeDocker struct {
|
|||||||
create []recordedCreate
|
create []recordedCreate
|
||||||
archives []int // sizes of accepted CopyToContainer tar streams
|
archives []int // sizes of accepted CopyToContainer tar streams
|
||||||
|
|
||||||
failBuild bool
|
failBuild bool
|
||||||
failBuildHTTP bool
|
failBuildHTTP bool
|
||||||
failCreate bool
|
failCreate bool
|
||||||
failStart bool
|
failStart bool
|
||||||
failStop bool
|
failStop bool
|
||||||
failWait bool
|
failWait bool
|
||||||
failVolumeCreate bool
|
failVolumeCreate bool
|
||||||
failVolumeDelete bool
|
failVolumeDelete bool
|
||||||
failArchive bool
|
failVolumeInspect bool
|
||||||
archiveHang bool
|
failArchive bool
|
||||||
waitHang bool
|
archiveHang bool
|
||||||
|
waitHang bool
|
||||||
|
|
||||||
unknown []string
|
unknown []string
|
||||||
}
|
}
|
||||||
@@ -203,6 +204,10 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
exists := f.volume[name]
|
exists := f.volume[name]
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
if f.failVolumeInspect {
|
||||||
|
writeJSONNow(w, http.StatusInternalServerError, `{"message":"transient docker error"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
if exists {
|
if exists {
|
||||||
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
|
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
|
||||||
return
|
return
|
||||||
@@ -288,10 +293,12 @@ const (
|
|||||||
fakeGitModeFail string = "fail"
|
fakeGitModeFail string = "fail"
|
||||||
fakeGitModeNoisy string = "noisy"
|
fakeGitModeNoisy string = "noisy"
|
||||||
fakeGitModeSilent string = "silent"
|
fakeGitModeSilent string = "silent"
|
||||||
|
fakeGitModeHang string = "hang"
|
||||||
)
|
)
|
||||||
|
|
||||||
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
|
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
|
||||||
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests).
|
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests),
|
||||||
|
// hang (sleeps; only ctx cancellation can end it).
|
||||||
func useFakeGit(t *testing.T, mode string) string {
|
func useFakeGit(t *testing.T, mode string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -302,6 +309,7 @@ func useFakeGit(t *testing.T, mode string) string {
|
|||||||
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
|
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
|
||||||
" 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" +
|
||||||
|
" hang) exec sleep 30;;\n" +
|
||||||
"esac\n" +
|
"esac\n" +
|
||||||
"# branch probe: emit the configured HEAD name\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" +
|
"if [ \"$1\" = '-C' ] && [ \"$3\" = rev-parse ]; then printf '%s' \"$FAKE_GIT_HEAD\"; exit 0; fi\n" +
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,7 @@ module lvmh-daemon
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
@@ -11,7 +12,6 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
|||||||
+243
-23
@@ -6,6 +6,8 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -75,7 +78,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,
|
volumeRepoPrefix + repoSlug("group/project") + ":" + workspaceMount,
|
||||||
volumeSessions + ":" + sessionsMount,
|
volumeSessions + ":" + sessionsMount,
|
||||||
volumePiCache + ":" + cacheMount,
|
volumePiCache + ":" + cacheMount,
|
||||||
}
|
}
|
||||||
@@ -102,7 +105,7 @@ 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{volumeRepoPrefix + repoSlug("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)
|
||||||
}
|
}
|
||||||
@@ -260,7 +263,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err != nil {
|
if err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project")); err != nil {
|
||||||
t.Fatalf("cloneOrUpdate: %v", err)
|
t.Fatalf("cloneOrUpdate: %v", err)
|
||||||
}
|
}
|
||||||
calls := readGitLog(t, gitLog)
|
calls := readGitLog(t, gitLog)
|
||||||
@@ -343,7 +346,9 @@ func TestSpawnerSeedVolumeCancel(t *testing.T) {
|
|||||||
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, repoSlug("group/project"), "lvmh-repo-group--project") }()
|
go func() {
|
||||||
|
done <- sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1")
|
||||||
|
}()
|
||||||
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 {
|
||||||
@@ -386,12 +391,24 @@ 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" {
|
slugRe := regexp.MustCompile(`^a--b--c-[0-9a-f]{6}$`)
|
||||||
t.Fatalf("repoSlug = %q", got)
|
if got := repoSlug("a/b/c"); !slugRe.MatchString(got) {
|
||||||
|
t.Fatalf("repoSlug = %q, want a--b--c-<6 hex>", got)
|
||||||
}
|
}
|
||||||
// "a/b/c" and "a/b-c" must map to distinct slugs (no collision).
|
// sanitized path + first 6 hex of sha256(full repo path)
|
||||||
if repoSlug("a/b/c") == repoSlug("a/b-c") {
|
sum := sha256.Sum256([]byte("a/b/c"))
|
||||||
t.Fatalf("slug collision: %q", repoSlug("a/b/c"))
|
want := "a--b--c-" + hex.EncodeToString(sum[:3])
|
||||||
|
if got := repoSlug("a/b/c"); got != want {
|
||||||
|
t.Fatalf("repoSlug = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// "/"→"--" alone is ambiguous: distinct repo paths must never collide.
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, repo := range []string{"a/b/c", "a/b-c", "a/b--c", "a/b--c/d"} {
|
||||||
|
s := repoSlug(repo)
|
||||||
|
if seen[s] {
|
||||||
|
t.Fatalf("slug collision: %q", s)
|
||||||
|
}
|
||||||
|
seen[s] = true
|
||||||
}
|
}
|
||||||
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] != '-' {
|
||||||
@@ -473,10 +490,10 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
|
|||||||
if !strings.Contains(job.Message, "seed") {
|
if !strings.Contains(job.Message, "seed") {
|
||||||
t.Fatalf("job message = %q, want seed failure", job.Message)
|
t.Fatalf("job message = %q, want seed failure", job.Message)
|
||||||
}
|
}
|
||||||
if !f.hasCall(http.MethodDelete, "/volumes/lvmh-repo-group--project") {
|
if !f.hasCall(http.MethodDelete, "/volumes/"+volumeRepoPrefix+repoSlug("group/project")) {
|
||||||
t.Fatal("failed seed must force-remove the repo volume for a fresh retry")
|
t.Fatal("failed seed must force-remove the repo volume for a fresh retry")
|
||||||
}
|
}
|
||||||
if f.volumeExists("lvmh-repo-group--project") {
|
if f.volumeExists(volumeRepoPrefix + repoSlug("group/project")) {
|
||||||
t.Fatal("repo volume must not linger half-seeded")
|
t.Fatal("repo volume must not linger half-seeded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +523,7 @@ func TestSpawnerCloneOrUpdateBranchFetchFails(t *testing.T) {
|
|||||||
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug, ".git"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug, ".git"), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir .git: %v", err)
|
t.Fatalf("mkdir .git: %v", err)
|
||||||
}
|
}
|
||||||
err := sp.cloneOrUpdate("group/project", "dev", slug)
|
err := sp.cloneOrUpdate(context.Background(), "group/project", "dev", slug)
|
||||||
if err == nil || !strings.Contains(err.Error(), "git fetch") {
|
if err == nil || !strings.Contains(err.Error(), "git fetch") {
|
||||||
t.Fatalf("cloneOrUpdate branch fetch failure = %v, want git fetch error", err)
|
t.Fatalf("cloneOrUpdate branch fetch failure = %v, want git fetch error", err)
|
||||||
}
|
}
|
||||||
@@ -564,7 +581,7 @@ func TestSpawnerCloneOrUpdateSwitchesBranch(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
if err := sp.cloneOrUpdate("group/project", tc.branch, slug); err != nil {
|
if err := sp.cloneOrUpdate(context.Background(), "group/project", tc.branch, slug); err != nil {
|
||||||
t.Fatalf("cloneOrUpdate: %v", err)
|
t.Fatalf("cloneOrUpdate: %v", err)
|
||||||
}
|
}
|
||||||
calls := readGitLog(t, gitLog)
|
calls := readGitLog(t, gitLog)
|
||||||
@@ -674,7 +691,7 @@ func TestTarDir(t *testing.T) {
|
|||||||
|
|
||||||
func TestGitRunSilentFailure(t *testing.T) {
|
func TestGitRunSilentFailure(t *testing.T) {
|
||||||
useFakeGit(t, fakeGitModeSilent)
|
useFakeGit(t, fakeGitModeSilent)
|
||||||
err := gitRun("", "clone", "x")
|
err := gitRun(context.Background(), "", "clone", "x")
|
||||||
if err == nil || strings.Contains(err.Error(), "clone x") {
|
if err == nil || strings.Contains(err.Error(), "clone x") {
|
||||||
// silent failure surfaces the bare exec error, not a padded message
|
// silent failure surfaces the bare exec error, not a padded message
|
||||||
t.Fatalf("gitRun silent failure = %v", err)
|
t.Fatalf("gitRun silent failure = %v", err)
|
||||||
@@ -723,7 +740,7 @@ func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
|
|||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
// unparseable repo path (control char) → cloneURL parse error
|
// unparseable repo path (control char) → cloneURL parse error
|
||||||
if err := sp.cloneOrUpdate("bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
|
if err := sp.cloneOrUpdate(context.Background(), "bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
|
||||||
t.Fatal("cloneOrUpdate with control-char repo must fail")
|
t.Fatal("cloneOrUpdate with control-char repo must fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +750,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", "", repoSlug("group/project")); err == nil {
|
if err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project")); err == nil {
|
||||||
t.Fatal("cloneOrUpdate with file reposDir must fail")
|
t.Fatal("cloneOrUpdate with file reposDir must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,7 +787,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[volumeRepoPrefix+repoSlug("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", "")
|
||||||
@@ -790,12 +807,12 @@ func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
f.failCreate = true
|
f.failCreate = true
|
||||||
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
|
if err := sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1"); 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, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
|
if err := sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1"); err == nil {
|
||||||
t.Fatal("seedVolume with failing start must fail")
|
t.Fatal("seedVolume with failing start must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,7 +853,7 @@ func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project"))
|
err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project"))
|
||||||
if err == nil || !strings.Contains(err.Error(), "git pull") {
|
if err == nil || !strings.Contains(err.Error(), "git pull") {
|
||||||
t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err)
|
t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err)
|
||||||
}
|
}
|
||||||
@@ -847,7 +864,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[volumeRepoPrefix+repoSlug("group/project")] = true
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
f.failCreate = true
|
f.failCreate = true
|
||||||
@@ -959,11 +976,214 @@ func TestTarDirUnreadableFile(t *testing.T) {
|
|||||||
func TestGitRunUnderivableExit(t *testing.T) {
|
func TestGitRunUnderivableExit(t *testing.T) {
|
||||||
// gitRun is the exec seam: with real git present, invoking a nonexistent
|
// gitRun is the exec seam: with real git present, invoking a nonexistent
|
||||||
// subcommand must surface stderr, and trimming applies to long output.
|
// subcommand must surface stderr, and trimming applies to long output.
|
||||||
if err := gitRun("", "version"); err != nil {
|
if err := gitRun(context.Background(), "", "version"); err != nil {
|
||||||
t.Fatalf("git version: %v", err)
|
t.Fatalf("git version: %v", err)
|
||||||
}
|
}
|
||||||
err := gitRun("", "this-subcommand-does-not-exist")
|
err := gitRun(context.Background(), "", "this-subcommand-does-not-exist")
|
||||||
if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") {
|
if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") {
|
||||||
t.Fatalf("gitRun unknown subcommand = %v", err)
|
t.Fatalf("gitRun unknown subcommand = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- round-2 regression tests ---
|
||||||
|
|
||||||
|
// TestTarDirSymlinks pins the symlink contract: headers carry TypeSymlink,
|
||||||
|
// the real target in Linkname, and no content body (Size 0). Docker's untar
|
||||||
|
// recreates symlinks via os.Symlink(Linkname, path); an empty Linkname made
|
||||||
|
// every symlink-bearing repo fail to seed permanently.
|
||||||
|
func TestTarDirSymlinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("file.txt", filepath.Join(dir, "rel-link")); err != nil {
|
||||||
|
t.Fatalf("rel symlink: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(dir, "file.txt"), filepath.Join(dir, "abs-link")); err != nil {
|
||||||
|
t.Fatalf("abs symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tarDir(&buf, dir); err != nil {
|
||||||
|
t.Fatalf("tarDir: %v", err)
|
||||||
|
}
|
||||||
|
type linkInfo struct {
|
||||||
|
linkname string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
links := map[string]linkInfo{}
|
||||||
|
regulars := 0
|
||||||
|
tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tar next: %v", err)
|
||||||
|
}
|
||||||
|
if hdr.Typeflag == tar.TypeSymlink {
|
||||||
|
links[hdr.Name] = linkInfo{hdr.Linkname, hdr.Size}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hdr.Typeflag == tar.TypeReg {
|
||||||
|
regulars++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if regulars != 1 {
|
||||||
|
t.Fatalf("regular files = %d, want 1", regulars)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"rel-link": "file.txt",
|
||||||
|
"abs-link": filepath.Join(dir, "file.txt"),
|
||||||
|
}
|
||||||
|
for name, target := range want {
|
||||||
|
got, ok := links[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("symlink %q missing from archive (have %v)", name, links)
|
||||||
|
}
|
||||||
|
if got.linkname != target {
|
||||||
|
t.Fatalf("symlink %q Linkname = %q, want %q", name, got.linkname, target)
|
||||||
|
}
|
||||||
|
if got.size != 0 {
|
||||||
|
t.Fatalf("symlink %q has Size %d, want 0 (no content body)", name, got.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnerRemoveSessionSurvivesClientHangup: a cancelled request context
|
||||||
|
// (client hangup) must not skip the container remove — the DB row is deleted
|
||||||
|
// either way, so a skipped remove would orphan the container forever.
|
||||||
|
func TestSpawnerRemoveSessionSurvivesClientHangup(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, store := newTestSpawner(t, f)
|
||||||
|
|
||||||
|
if err := store.UpsertContainer("s1", "cid-9", "group/project"); err != nil {
|
||||||
|
t.Fatalf("upsert container: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // client already gone
|
||||||
|
if err := sp.RemoveSession(ctx, "s1"); err != nil {
|
||||||
|
t.Fatalf("RemoveSession with cancelled ctx: %v", err)
|
||||||
|
}
|
||||||
|
if !f.hasCall(http.MethodDelete, "/containers/cid-9") {
|
||||||
|
t.Fatal("remove must run on a background ctx despite the cancelled request ctx")
|
||||||
|
}
|
||||||
|
if _, ok, _ := store.GetContainer("s1"); ok {
|
||||||
|
t.Fatal("container row must be deleted")
|
||||||
|
}
|
||||||
|
f.assertNoUnknown(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnerSeedVolumeUniqueNames: two seeds for the same repo must not
|
||||||
|
// reuse one container name — a crash between create and the deferred remove
|
||||||
|
// would then block every future spawn with a name conflict.
|
||||||
|
func TestSpawnerSeedVolumeUniqueNames(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
|
slug := repoSlug("group/project")
|
||||||
|
repoVolume := volumeRepoPrefix + slug
|
||||||
|
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir clone dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sp.reposDir, slug, "README.md"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write README: %v", err)
|
||||||
|
}
|
||||||
|
for _, sid := range []string{"aaaaaaaa-1111-2222-3333-444444444444", "bbbbbbbb-1111-2222-3333-444444444444"} {
|
||||||
|
if err := sp.seedVolume(context.Background(), slug, repoVolume, sid); err != nil {
|
||||||
|
t.Fatalf("seedVolume(%s): %v", sid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seeds := f.createsByName("lvmh-seed-")
|
||||||
|
if len(seeds) != 2 {
|
||||||
|
t.Fatalf("seed containers = %+v, want 2", seeds)
|
||||||
|
}
|
||||||
|
if seeds[0].Name == seeds[1].Name {
|
||||||
|
t.Fatalf("seed names must differ per session: %q", seeds[0].Name)
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
if !strings.HasPrefix(s.Name, "lvmh-seed-"+slug+"-") {
|
||||||
|
t.Fatalf("seed name %q, want prefix lvmh-seed-%s-", s.Name, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.assertNoUnknown(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnerEnsureRepoVolumeTransientInspectError: a non-NotFound volume
|
||||||
|
// inspect error must propagate, never be treated as "fresh" (which would
|
||||||
|
// seed over an existing volume).
|
||||||
|
func TestSpawnerEnsureRepoVolumeTransientInspectError(t *testing.T) {
|
||||||
|
f := newFakeDocker()
|
||||||
|
f.failVolumeInspect = true
|
||||||
|
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // volume exists
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
|
fresh, err := sp.ensureRepoVolume(context.Background(), volumeRepoPrefix+repoSlug("group/project"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "transient docker error") {
|
||||||
|
t.Fatalf("ensureRepoVolume = fresh:%v err:%v, want transient error propagated", fresh, err)
|
||||||
|
}
|
||||||
|
if fresh {
|
||||||
|
t.Fatal("transient inspect error must never report a fresh volume")
|
||||||
|
}
|
||||||
|
if f.countCalls(http.MethodPost, "/volumes/create") != 0 {
|
||||||
|
t.Fatal("no volume create may follow a transient inspect error")
|
||||||
|
}
|
||||||
|
f.assertNoUnknown(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnerCloneOrUpdateRespectsCtx: git spawned via CommandContext must
|
||||||
|
// die when the context is cancelled (daemon shutdown must not hang on git).
|
||||||
|
func TestSpawnerCloneOrUpdateRespectsCtx(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeHang) // clone sleeps 30s
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
err := sp.cloneOrUpdate(ctx, "group/project", "", repoSlug("group/project"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("cloneOrUpdate must fail when ctx expires mid-clone")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("cloneOrUpdate took %v, want prompt cancellation", elapsed)
|
||||||
|
}
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
t.Fatal("expected the context to be the failure cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitBranchRespectsCtx: the branch probe must honour cancellation too.
|
||||||
|
func TestGitBranchRespectsCtx(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeHang)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
if got := gitBranch(ctx, t.TempDir()); got != "" {
|
||||||
|
t.Fatalf("gitBranch under cancelled ctx = %q, want \"\"", got)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("gitBranch took %v, want prompt cancellation", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnerStartDedupesImageList: Start must list images exactly once
|
||||||
|
// (plus once more inside ensureImage) — a regression guard for the removed
|
||||||
|
// double imageExists call.
|
||||||
|
func TestSpawnerStartDedupesImageList(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
res, err := sp.Start(context.Background(), "group/project", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||||
|
if n := f.countCalls(http.MethodGet, "/images/json"); n != 2 {
|
||||||
|
t.Fatalf("image list calls = %d, want 2 (Start + ensureImage)", n)
|
||||||
|
}
|
||||||
|
f.assertNoUnknown(t)
|
||||||
|
}
|
||||||
|
|||||||
@@ -504,9 +504,7 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
? (maxAssignedSeq.get(currentSessionId) ?? 0)
|
? (maxAssignedSeq.get(currentSessionId) ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
if (lastSeq > maxAssigned) {
|
if (lastSeq > maxAssigned) {
|
||||||
const covered: number = replayBuf.filter(
|
const covered: number = replayBuf.filter((f) => f.seq <= lastSeq).length;
|
||||||
(f) => f.seq <= lastSeq,
|
|
||||||
).length;
|
|
||||||
if (covered > 0) {
|
if (covered > 0) {
|
||||||
droppedEvents += covered;
|
droppedEvents += covered;
|
||||||
log(
|
log(
|
||||||
|
|||||||
+22
-4
@@ -102,6 +102,24 @@ describe("App gate", () => {
|
|||||||
);
|
);
|
||||||
await screen.findByLabelText("Sessions");
|
await screen.findByLabelText("Sessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("first-run: saving settings starts the ws manager (A)", async () => {
|
||||||
|
seedApi();
|
||||||
|
renderApp();
|
||||||
|
fireEvent.input(screen.getByLabelText("Bearer token"), {
|
||||||
|
target: { value: "tok" },
|
||||||
|
});
|
||||||
|
fireEvent.submit(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Connect" })
|
||||||
|
.closest("form") as HTMLFormElement,
|
||||||
|
);
|
||||||
|
// before the fix the effect deps never changed after the gate save,
|
||||||
|
// so no manager ever started and the app stayed on the connecting banner
|
||||||
|
await screen.findByLabelText("Sessions");
|
||||||
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
|
await screen.findByText(/reconnecting/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("App shell", () => {
|
describe("App shell", () => {
|
||||||
@@ -113,9 +131,7 @@ describe("App shell", () => {
|
|||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
expect(
|
expect(screen.getByTitle("ws open")).toBeInTheDocument();
|
||||||
screen.getByTitle("ws open"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
const sidebar = screen.getByLabelText("Sessions");
|
const sidebar = screen.getByLabelText("Sessions");
|
||||||
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
|
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
|
||||||
@@ -229,7 +245,9 @@ describe("ErrorBoundary direct", () => {
|
|||||||
function Bomb(): React.ReactNode {
|
function Bomb(): React.ReactNode {
|
||||||
throw new Error("kaboom-ui");
|
throw new Error("kaboom-ui");
|
||||||
}
|
}
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
function Harness(): React.ReactNode {
|
function Harness(): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ export default function App() {
|
|||||||
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
|
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
|
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
|
||||||
const { toasts, push } = useToasts();
|
const { toasts, push } = useToasts();
|
||||||
const store = useSessions(push);
|
const store = useSessions(push, configured);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+367
-440
@@ -1,483 +1,410 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } 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, SessionListItem } from "./protocol";
|
import type { ChatMessage, ToolState } from "./derive";
|
||||||
import { Route as ApiRoute } from "./protocol";
|
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||||
import { fetchJson } from "./api";
|
|
||||||
import SpawnView from "./SpawnView";
|
|
||||||
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<SessionListItem[]> =>
|
thinking: null,
|
||||||
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
|
toolCalls: [],
|
||||||
subscribe: (): (() => void) => () => undefined,
|
toolCallId: null,
|
||||||
...over,
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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(() => {
|
describe("Bubble", () => {
|
||||||
vi.restoreAllMocks();
|
it("renders plain text per role class", () => {
|
||||||
});
|
const { container } = render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({ role: "user", text: "hi there" })}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("hi there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||||
|
const long = `${"x".repeat(200)}`;
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({ role: "toolResult", text: long })}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const details = screen
|
||||||
|
.getByText("result")
|
||||||
|
.closest("details") as HTMLDetailsElement;
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
expect(details.textContent).toContain("…");
|
||||||
|
await userEvent.click(screen.getByText("result"));
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
expect(details.textContent).toContain(long);
|
||||||
|
});
|
||||||
|
|
||||||
describe("SpawnView status", () => {
|
it("toolResult with short text keeps full one-line preview", () => {
|
||||||
it("shows checking state, then error when gitlab status fails", async () => {
|
render(
|
||||||
mockFetchJson((url) => {
|
<Bubble
|
||||||
if (url.includes("/api/gitlab/status"))
|
msg={msg({ role: "toolResult", text: "short out" })}
|
||||||
return jsonResponse({ error: "down" }, 500);
|
tools={new Map()}
|
||||||
return [];
|
/>,
|
||||||
});
|
);
|
||||||
render(tree(makeStore()));
|
const details = screen
|
||||||
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
.getByText("result")
|
||||||
await flush();
|
.closest("details") as HTMLDetailsElement;
|
||||||
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
expect(details.textContent).toContain("short out");
|
||||||
|
expect(details.textContent).not.toContain("…");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("SpawnView connect flow", () => {
|
it("flattens whitespace in previews", () => {
|
||||||
it("requires a token", async () => {
|
render(
|
||||||
mockFetchJson((url) => {
|
<Bubble
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
|
||||||
return { connected: false, baseUrl: "https://gl" };
|
tools={new Map()}
|
||||||
return [];
|
/>,
|
||||||
});
|
);
|
||||||
render(tree(makeStore()));
|
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
|
||||||
await flush();
|
|
||||||
expect(screen.getByText("token required")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("assistant tool calls attach tool cards", async () => {
|
||||||
|
const tools = new Map<string, ToolState>([
|
||||||
|
[
|
||||||
|
"c1",
|
||||||
|
tool({
|
||||||
|
id: "c1",
|
||||||
|
name: "bash",
|
||||||
|
args: "ls -la",
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "file",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
role: "assistant",
|
||||||
|
text: "finished",
|
||||||
|
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={tools}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||||
|
|
||||||
it("connects, clears the PAT, loads repos and shows the picker", async () => {
|
const summary = screen
|
||||||
let connected = false;
|
.getByText("🛠 bash")
|
||||||
mockFetchJson((url, init) => {
|
.closest("summary") as HTMLElement;
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
const card = summary.closest("details") as HTMLDetailsElement;
|
||||||
return {
|
expect(card.open).toBe(false);
|
||||||
connected,
|
await userEvent.click(summary);
|
||||||
baseUrl: "https://gl",
|
expect(card.open).toBe(true);
|
||||||
username: connected ? "alice" : undefined,
|
expect(card.textContent).toContain("ls -la");
|
||||||
};
|
expect(card.textContent).toContain("file");
|
||||||
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 () => {
|
it("tool card status variants: running, error, done", () => {
|
||||||
mockFetchJson((url, init) => {
|
const tools = new Map<string, ToolState>([
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
["c1", tool({ id: "c1", running: true })],
|
||||||
return { connected: false, baseUrl: "https://gl" };
|
["c2", tool({ id: "c2", running: false, isError: true })],
|
||||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
|
["c3", tool({ id: "c3", running: false, isError: false })],
|
||||||
return jsonResponse({ error: "bad pat" }, 401);
|
]);
|
||||||
return [];
|
render(
|
||||||
});
|
<Bubble
|
||||||
render(tree(makeStore()));
|
msg={msg({
|
||||||
await flush();
|
role: "assistant",
|
||||||
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
|
toolCalls: ["c1", "c2", "c3"].map((id) => ({
|
||||||
target: { value: "glpat-bad" },
|
id,
|
||||||
});
|
name: `t-${id}`,
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
argsJson: "{}",
|
||||||
await flush();
|
})),
|
||||||
expect(screen.getByText("bad pat")).toBeInTheDocument();
|
})}
|
||||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
tools={tools}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("working…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("error")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("done")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connected on load fetches repos immediately", async () => {
|
it("tool call with no matching state renders no card", () => {
|
||||||
mockFetchJson((url) => {
|
const { container } = render(
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
<Bubble
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
msg={msg({
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
|
role: "assistant",
|
||||||
return [];
|
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
|
||||||
});
|
})}
|
||||||
render(tree(makeStore()));
|
tools={new Map()}
|
||||||
await flush();
|
/>,
|
||||||
expect(screen.getByText("g/quick")).toBeInTheDocument();
|
);
|
||||||
|
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("SpawnView repo picker", () => {
|
it("thinking block only for non-empty thinking", async () => {
|
||||||
function connectedMock(): void {
|
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
||||||
mockFetchJson((url) => {
|
const details = screen
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
.getByText("thinking")
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
.closest("details") as HTMLDetailsElement;
|
||||||
if (url.endsWith("/api/gitlab/repos"))
|
await userEvent.click(screen.getByText("thinking"));
|
||||||
return [repo("g/alpha"), repo("g/beta", "dev")];
|
expect(details.open).toBe(true);
|
||||||
return [];
|
expect(details.textContent).toContain("because");
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
const { container } = render(
|
||||||
connectedMock();
|
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
||||||
render(tree(makeStore()));
|
);
|
||||||
await flush();
|
expect(container.querySelector(".thinking")).toBeNull();
|
||||||
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 () => {
|
it("streaming bubble shows the caret", () => {
|
||||||
const gate = { resolve: null as ((v: unknown) => void) | null };
|
const { container } = render(
|
||||||
mockFetchJson((url) => {
|
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
);
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
||||||
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 () => {
|
describe("TypingIndicator", () => {
|
||||||
connectedMock();
|
it("renders three dots with aria-live", () => {
|
||||||
render(tree(makeStore()));
|
const { container } = render(<TypingIndicator />);
|
||||||
await flush();
|
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
||||||
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
||||||
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".typing")).not.toBeNull();
|
||||||
|
|
||||||
describe("SpawnView spawn+poll", () => {
|
rerender(
|
||||||
beforeEach(() => {
|
stream({
|
||||||
vi.useFakeTimers();
|
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("spawns, polls until online, then navigates to the chat", async () => {
|
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||||
const posts: Array<[string, RequestInit | undefined]> = [];
|
const { container, rerender } = render(
|
||||||
let sessionsOnline = false; // flipped after the first poll tick
|
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||||
const fetchMock = mockFetchJson((url, init) => {
|
);
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||||
posts.push([url, init]);
|
Object.defineProperty(scroller, "scrollHeight", {
|
||||||
return { sessionId: "new-1", containerId: "abc123def456" };
|
configurable: true,
|
||||||
}
|
value: 1000,
|
||||||
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();
|
Object.defineProperty(scroller, "clientHeight", {
|
||||||
const { unmount } = render(tree(store));
|
configurable: true,
|
||||||
await flush();
|
value: 300,
|
||||||
fireEvent.click(screen.getByText("g/proj"));
|
});
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
// pinned: scrollTop at bottom
|
||||||
expect(posts).toHaveLength(1);
|
scroller.scrollTop = 700;
|
||||||
expect((posts[0]?.[1] as RequestInit).body).toBe(
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
JSON.stringify({ repo: "g/proj", branch: "main" }),
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: 700,
|
||||||
|
});
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
rerender(
|
||||||
|
stream({
|
||||||
|
messages: [msg({ key: "a" }), msg({ key: "b" })],
|
||||||
|
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(
|
||||||
|
stream({
|
||||||
|
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
|
||||||
|
busy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(0);
|
||||||
|
|
||||||
|
// scroll near bottom (within 80px) -> pinned again
|
||||||
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: 940,
|
||||||
|
});
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
rerender(
|
||||||
|
stream({
|
||||||
|
messages: [
|
||||||
|
msg({ key: "a" }),
|
||||||
|
msg({ key: "b" }),
|
||||||
|
msg({ key: "c" }),
|
||||||
|
msg({ key: "d" }),
|
||||||
|
],
|
||||||
|
busy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load-older button renders only when an older page exists and fires on click", () => {
|
||||||
|
const onOlder = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("waiting for session to come online…"),
|
screen.queryByRole("button", { name: "Load older messages" }),
|
||||||
).toBeInTheDocument();
|
).toBeNull();
|
||||||
|
|
||||||
// first tick: session not online yet
|
rerender(
|
||||||
await act(async () => {
|
stream({
|
||||||
vi.advanceTimersByTime(1500);
|
messages: [msg({ key: "a" })],
|
||||||
});
|
busy: false,
|
||||||
await flush();
|
hasOlder: true,
|
||||||
expect(screen.queryByTestId("chat-route")).toBeNull();
|
onOlder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Load older messages" });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onOlder).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// each poll tick issues exactly one sessions fetch (refresh reuse, S7)
|
rerender(
|
||||||
const sessionsFetches = fetchMock.mock.calls.filter(([u]) =>
|
stream({
|
||||||
String(u).endsWith("/api/sessions"),
|
messages: [msg({ key: "a" })],
|
||||||
).length;
|
busy: false,
|
||||||
expect(sessionsFetches).toBe(1);
|
hasOlder: true,
|
||||||
|
loadingOlder: true,
|
||||||
// session comes online -> next tick navigates
|
onOlder,
|
||||||
sessionsOnline = true;
|
}),
|
||||||
await act(async () => {
|
);
|
||||||
vi.advanceTimersByTime(1500);
|
expect(
|
||||||
});
|
screen.getByRole("button", { name: "Load older messages" }),
|
||||||
await flush();
|
).toBeDisabled();
|
||||||
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
|
|
||||||
unmount();
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("spawning view shows job line from status and spawn error text", async () => {
|
describe("copy button", () => {
|
||||||
mockFetchJson((url, init) => {
|
it("copy button writes message text and flashes copied", async () => {
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
vi.useFakeTimers();
|
||||||
return { sessionId: "sx", containerId: "" };
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
Object.assign(navigator, { clipboard: { writeText } });
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
render(
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
<Bubble
|
||||||
return [];
|
msg={{
|
||||||
});
|
key: "k",
|
||||||
const store = makeStore({
|
role: "assistant",
|
||||||
spawnJobs: [
|
text: "copy me",
|
||||||
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
|
thinking: null,
|
||||||
],
|
toolCalls: [],
|
||||||
});
|
toolCallId: null,
|
||||||
render(tree(store));
|
streaming: false,
|
||||||
await flush();
|
}}
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
tools={new Map()}
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
/>,
|
||||||
await flush();
|
);
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
const btn = screen.getByRole("button", { name: "Copy message" });
|
||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
fireEvent.click(btn);
|
||||||
});
|
expect(writeText).toHaveBeenCalledWith("copy me");
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(screen.getByText("copied")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
// feedback clears after COPY_FEEDBACK_MS (H)
|
||||||
const bodies: string[] = [];
|
|
||||||
mockFetchJson((url, init) => {
|
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
|
||||||
bodies.push(String(init.body));
|
|
||||||
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const failingRefresh = makeStore({
|
|
||||||
refresh: (): Promise<SessionListItem[]> =>
|
|
||||||
Promise.reject(new Error("boom")),
|
|
||||||
});
|
|
||||||
render(tree(failingRefresh));
|
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByText("g/blank"));
|
|
||||||
fireEvent.change(screen.getByLabelText("Branch"), {
|
|
||||||
target: { value: "" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
vi.advanceTimersByTime(1500);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spawn POST failure shows the error", async () => {
|
|
||||||
mockFetchJson((url, init) => {
|
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
|
||||||
return jsonResponse({ error: "no docker" }, 500);
|
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
render(tree(makeStore()));
|
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByText("g/x"));
|
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
expect(screen.getByText("no docker")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("polling gives up after the tick cap and stays on the page", async () => {
|
|
||||||
let statusCalls = 0;
|
|
||||||
mockFetchJson((url) => {
|
|
||||||
if (url.endsWith("/api/spawn/status")) {
|
|
||||||
statusCalls += 1;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
|
||||||
if (url.endsWith("/api/spawn"))
|
|
||||||
return { sessionId: "slow-1", containerId: "d" };
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const { unmount } = render(tree(makeStore()));
|
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByText("g/slow"));
|
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
vi.advanceTimersByTime(1500 * 402);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
const afterCap = statusCalls;
|
|
||||||
await act(async () => {
|
|
||||||
vi.advanceTimersByTime(1500 * 10);
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
expect(statusCalls).toBe(afterCap);
|
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spawn job line renders from store.spawnJobs", async () => {
|
|
||||||
mockFetchJson((url, init) => {
|
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
|
||||||
return { sessionId: "sj-1", containerId: "cid" };
|
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
|
||||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const store = makeStore();
|
|
||||||
const { rerender } = render(tree(store));
|
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
|
||||||
|
|
||||||
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender(tree(store));
|
vi.advanceTimersByTime(1200);
|
||||||
});
|
});
|
||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
expect(screen.getByText("copy")).toBeInTheDocument();
|
||||||
});
|
vi.useRealTimers();
|
||||||
});
|
|
||||||
|
|
||||||
describe("SpawnSteps", () => {
|
|
||||||
it("renders progress steps matching job state", async () => {
|
|
||||||
mockFetchJson((url, init) => {
|
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
|
||||||
return { sessionId: "sp1", containerId: "" };
|
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
|
||||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const store = makeStore({
|
|
||||||
spawnJobs: [
|
|
||||||
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(tree(store));
|
|
||||||
await flush();
|
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
|
||||||
await flush();
|
|
||||||
const steps = document.querySelectorAll(".spawn-progress .step");
|
|
||||||
expect(steps).toHaveLength(4);
|
|
||||||
expect(steps[0]?.className).toContain("done");
|
|
||||||
expect(steps[1]?.className).toContain("current");
|
|
||||||
expect(steps[2]?.className).toBe("step");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("progress element carries progressbar semantics for the current step", async () => {
|
it("user bubbles get a copy button, toolResults do not", () => {
|
||||||
mockFetchJson((url, init) => {
|
const { rerender } = render(
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
<Bubble
|
||||||
return { sessionId: "sp2", containerId: "" };
|
msg={{
|
||||||
if (url.endsWith("/api/gitlab/status"))
|
key: "u",
|
||||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
role: "user",
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
text: "hi",
|
||||||
return [];
|
thinking: null,
|
||||||
});
|
toolCalls: [],
|
||||||
const store = makeStore({
|
toolCallId: null,
|
||||||
spawnJobs: [
|
streaming: false,
|
||||||
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
|
}}
|
||||||
],
|
tools={new Map()}
|
||||||
});
|
/>,
|
||||||
render(tree(store));
|
);
|
||||||
await flush();
|
expect(
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
screen.getByRole("button", { name: "Copy message" }),
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
).toBeInTheDocument();
|
||||||
await flush();
|
rerender(
|
||||||
const bar = screen.getByRole("progressbar");
|
<Bubble
|
||||||
expect(bar).toHaveAttribute("aria-valuemin", "1");
|
msg={{
|
||||||
expect(bar).toHaveAttribute("aria-valuemax", "4");
|
key: "t",
|
||||||
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
|
role: "toolResult",
|
||||||
|
text: "r",
|
||||||
|
thinking: null,
|
||||||
|
toolCalls: [],
|
||||||
|
toolCallId: "c1",
|
||||||
|
streaming: false,
|
||||||
|
}}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { ChatMessage, ToolState } from "./derive";
|
import type { ChatMessage, ToolState } from "./derive";
|
||||||
|
|
||||||
const PREVIEW_LEN: number = 120;
|
const PREVIEW_LEN: number = 120;
|
||||||
|
const COPY_FEEDBACK_MS: number = 1200;
|
||||||
|
const PIN_THRESHOLD_PX: number = 80;
|
||||||
|
|
||||||
function oneLine(text: string): string {
|
function oneLine(text: string): string {
|
||||||
const flat = text.replace(/\s+/g, " ").trim();
|
const flat = text.replace(/\s+/g, " ").trim();
|
||||||
@@ -18,7 +20,7 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard?.writeText(text).then(() => {
|
void navigator.clipboard?.writeText(text).then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1200);
|
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -156,7 +158,8 @@ export default function ChatStream({
|
|||||||
const onScroll = (): void => {
|
const onScroll = (): void => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el === null) return;
|
if (el === null) return;
|
||||||
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
pinnedRef.current =
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -313,6 +313,25 @@ describe("ChatView", () => {
|
|||||||
expect(ta.value).toBe("");
|
expect(ta.value).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Enter during IME composition confirms candidates, not a send (E)", async () => {
|
||||||
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
|
);
|
||||||
|
renderChat(makeStore());
|
||||||
|
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||||
|
await userEvent.type(ta, "こん");
|
||||||
|
fireEvent.keyDown(ta, { key: "Enter", isComposing: true });
|
||||||
|
const posts = (): number =>
|
||||||
|
fetchMock.mock.calls.filter(
|
||||||
|
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
|
||||||
|
).length;
|
||||||
|
expect(posts()).toBe(0);
|
||||||
|
expect(ta.value).toBe("こん"); // composition text survives the confirm
|
||||||
|
|
||||||
|
fireEvent.keyDown(ta, { key: "Enter" }); // composition finished: real send
|
||||||
|
await vi.waitFor(() => expect(posts()).toBe(1));
|
||||||
|
});
|
||||||
|
|
||||||
it("abort posts to the abort route", async () => {
|
it("abort posts to the abort route", async () => {
|
||||||
const fetchMock = mockFetchJson((_url, init) =>
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
init?.method === "POST" ? { ok: true } : [],
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
@@ -810,6 +829,92 @@ describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
|
|||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
||||||
expect(ta.value).toBe("precious draft");
|
expect(ta.value).toBe("precious draft");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("session switch clears the draft instead of leaking it (B)", async () => {
|
||||||
|
mockFetchJson(() => []);
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||||
|
<Switcher state="open" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||||
|
await userEvent.type(ta, "secret for s1");
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "switch session" }),
|
||||||
|
);
|
||||||
|
await screen.findByText("ghost");
|
||||||
|
expect(ta.value).toBe(""); // s1's draft must not appear in ghost's composer
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session switch resets a stuck sending flag (B)", async () => {
|
||||||
|
let releaseSend: ((v: unknown) => void) | null = null;
|
||||||
|
mockFetchJson((_url, init) => {
|
||||||
|
if (init?.method === "POST")
|
||||||
|
return new Promise<unknown>((res) => {
|
||||||
|
releaseSend = res;
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||||
|
<Switcher state="open" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||||
|
await userEvent.type(ta, "hangs");
|
||||||
|
fireEvent.keyDown(ta, { key: "Enter" }); // send starts and never settles
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "switch session" }),
|
||||||
|
);
|
||||||
|
await screen.findByText("ghost");
|
||||||
|
const ta2 = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||||
|
await userEvent.type(ta2, "fresh");
|
||||||
|
// sending=true surviving the switch would keep the ghost button disabled
|
||||||
|
expect(screen.getByLabelText("Send message")).toBeEnabled();
|
||||||
|
await act(async () => {
|
||||||
|
releaseSend?.([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resubscribes only when the subscribe identity (manager) changes, not on store churn (G)", async () => {
|
||||||
|
mockFetchJson(() => []);
|
||||||
|
let subscribeCalls: number = 0;
|
||||||
|
const makeSubscribe =
|
||||||
|
(): SessionsStore["subscribe"] => (sessionId, onEvents) => {
|
||||||
|
subscribeCalls += 1;
|
||||||
|
currentSub = { sessionId, onEvents };
|
||||||
|
return () => {
|
||||||
|
if (currentSub !== null && currentSub.sessionId === sessionId)
|
||||||
|
currentSub = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const liveSubscribe = makeSubscribe();
|
||||||
|
const { rerender } = renderChat({
|
||||||
|
...makeStore(),
|
||||||
|
subscribe: liveSubscribe,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(subscribeCalls).toBe(1));
|
||||||
|
|
||||||
|
// store identity changes with every session_list frame; the live
|
||||||
|
// subscription must survive instead of unsubscribe/resubscribe churn
|
||||||
|
const churned = makeStore({
|
||||||
|
sessions: [{ ...sessions[0]!, online: false }],
|
||||||
|
});
|
||||||
|
rerenderChatAgain(rerender, { ...churned, subscribe: liveSubscribe });
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(subscribeCalls).toBe(1);
|
||||||
|
expect(currentSub).not.toBeNull();
|
||||||
|
|
||||||
|
// manager replaced -> new subscribe identity -> resubscribe happens
|
||||||
|
rerenderChatAgain(rerender, { ...makeStore(), subscribe: makeSubscribe() });
|
||||||
|
await waitFor(() => expect(subscribeCalls).toBe(2));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChatView unknown session", () => {
|
describe("ChatView unknown session", () => {
|
||||||
|
|||||||
+10
-2
@@ -76,6 +76,10 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
setEvents([]);
|
setEvents([]);
|
||||||
setHasOlder(false);
|
setHasOlder(false);
|
||||||
lastSeqRef.current = 0;
|
lastSeqRef.current = 0;
|
||||||
|
// a draft (or stuck sending state) from the previous session must not
|
||||||
|
// leak into the new one
|
||||||
|
setDraft("");
|
||||||
|
setSending(false);
|
||||||
let alive = true;
|
let alive = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -96,11 +100,13 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
};
|
};
|
||||||
}, [sessionId, applyEvents]);
|
}, [sessionId, applyEvents]);
|
||||||
|
|
||||||
// ws subscription
|
// ws subscription: dep on store.subscribe (stable per manager) rather than
|
||||||
|
// the whole store object — every sessions-list change would otherwise
|
||||||
|
// churn unsubscribe/resubscribe and can drop events in the gap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId.length === 0 || store.state !== "open") return;
|
if (sessionId.length === 0 || store.state !== "open") return;
|
||||||
return store.subscribe(sessionId, applyEvents);
|
return store.subscribe(sessionId, applyEvents);
|
||||||
}, [sessionId, store.state, store, applyEvents]);
|
}, [sessionId, store.state, store.subscribe, applyEvents]);
|
||||||
|
|
||||||
// missed events after reconnect (persisted only); a response for a
|
// missed events after reconnect (persisted only); a response for a
|
||||||
// previous session must not merge here nor touch the cursor (S1)
|
// previous session must not merge here nor touch the cursor (S1)
|
||||||
@@ -187,6 +193,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
// IME composition: Enter confirms the candidate window, not a send
|
||||||
|
if (e.nativeEvent.isComposing) return;
|
||||||
if (e.key === SEND_KEY && !e.shiftKey) {
|
if (e.key === SEND_KEY && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void send();
|
void send();
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ describe("SessionsView", () => {
|
|||||||
expect(skel).not.toBeNull();
|
expect(skel).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sessions arriving before the 600ms window end the skeleton immediately (F)", () => {
|
||||||
|
renderView({ sessions: [session({ id: "s1", name: "live" })] });
|
||||||
|
expect(document.querySelector(".skeleton")).toBeNull();
|
||||||
|
expect(screen.getByText("live")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders cards sorted by last activity with fallbacks", () => {
|
it("renders cards sorted by last activity with fallbacks", () => {
|
||||||
renderView({
|
renderView({
|
||||||
sessions: [
|
sessions: [
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export default function SessionsView({
|
|||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// real rows arriving end the skeleton window immediately: a later
|
||||||
|
// transient empty list must not flash skeletons again
|
||||||
|
if (sessions.length > 0) setLoaded(true);
|
||||||
|
}, [sessions.length]);
|
||||||
|
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
(id: string): void => {
|
(id: string): void => {
|
||||||
navigate(`/s/${id}`);
|
navigate(`/s/${id}`);
|
||||||
|
|||||||
+468
-365
@@ -1,400 +1,503 @@
|
|||||||
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>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush pending microtasks + React effects (works under fake timers). */
|
||||||
|
async function flush(ticks = 4): Promise<void> {
|
||||||
|
for (let i = 0; i < ticks; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
PUSH_TOAST.mockClear();
|
||||||
|
seedSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Bubble", () => {
|
afterEach(() => {
|
||||||
it("renders plain text per role class", () => {
|
vi.restoreAllMocks();
|
||||||
const { container } = render(
|
});
|
||||||
<Bubble
|
|
||||||
msg={msg({ role: "user", text: "hi there" })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
|
||||||
expect(container.textContent).toContain("hi there");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
describe("SpawnView status", () => {
|
||||||
const { container } = render(
|
it("shows checking state, then error when gitlab status fails", async () => {
|
||||||
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
mockFetchJson((url) => {
|
||||||
);
|
if (url.includes("/api/gitlab/status"))
|
||||||
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
return jsonResponse({ error: "down" }, 500);
|
||||||
});
|
return [];
|
||||||
|
});
|
||||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
render(tree(makeStore()));
|
||||||
const long = `${"x".repeat(200)}`;
|
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
||||||
render(
|
await flush();
|
||||||
<Bubble
|
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
||||||
msg={msg({ role: "toolResult", text: long })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const details = screen
|
|
||||||
.getByText("result")
|
|
||||||
.closest("details") as HTMLDetailsElement;
|
|
||||||
expect(details.open).toBe(false);
|
|
||||||
expect(details.textContent).toContain("…");
|
|
||||||
await userEvent.click(screen.getByText("result"));
|
|
||||||
expect(details.open).toBe(true);
|
|
||||||
expect(details.textContent).toContain(long);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toolResult with short text keeps full one-line preview", () => {
|
|
||||||
render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({ role: "toolResult", text: "short out" })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const details = screen
|
|
||||||
.getByText("result")
|
|
||||||
.closest("details") as HTMLDetailsElement;
|
|
||||||
expect(details.textContent).toContain("short out");
|
|
||||||
expect(details.textContent).not.toContain("…");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flattens whitespace in previews", () => {
|
|
||||||
render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("assistant tool calls attach tool cards", async () => {
|
|
||||||
const tools = new Map<string, ToolState>([
|
|
||||||
[
|
|
||||||
"c1",
|
|
||||||
tool({
|
|
||||||
id: "c1",
|
|
||||||
name: "bash",
|
|
||||||
args: "ls -la",
|
|
||||||
running: false,
|
|
||||||
isError: false,
|
|
||||||
preview: "file",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({
|
|
||||||
role: "assistant",
|
|
||||||
text: "finished",
|
|
||||||
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
|
||||||
})}
|
|
||||||
tools={tools}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const summary = screen
|
|
||||||
.getByText("🛠 bash")
|
|
||||||
.closest("summary") as HTMLElement;
|
|
||||||
const card = summary.closest("details") as HTMLDetailsElement;
|
|
||||||
expect(card.open).toBe(false);
|
|
||||||
await userEvent.click(summary);
|
|
||||||
expect(card.open).toBe(true);
|
|
||||||
expect(card.textContent).toContain("ls -la");
|
|
||||||
expect(card.textContent).toContain("file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tool card status variants: running, error, done", () => {
|
|
||||||
const tools = new Map<string, ToolState>([
|
|
||||||
["c1", tool({ id: "c1", running: true })],
|
|
||||||
["c2", tool({ id: "c2", running: false, isError: true })],
|
|
||||||
["c3", tool({ id: "c3", running: false, isError: false })],
|
|
||||||
]);
|
|
||||||
render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({
|
|
||||||
role: "assistant",
|
|
||||||
toolCalls: ["c1", "c2", "c3"].map((id) => ({
|
|
||||||
id,
|
|
||||||
name: `t-${id}`,
|
|
||||||
argsJson: "{}",
|
|
||||||
})),
|
|
||||||
})}
|
|
||||||
tools={tools}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("error")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("done")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tool call with no matching state renders no card", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<Bubble
|
|
||||||
msg={msg({
|
|
||||||
role: "assistant",
|
|
||||||
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
|
|
||||||
})}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("thinking block only for non-empty thinking", async () => {
|
|
||||||
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
|
||||||
const details = screen
|
|
||||||
.getByText("thinking")
|
|
||||||
.closest("details") as HTMLDetailsElement;
|
|
||||||
await userEvent.click(screen.getByText("thinking"));
|
|
||||||
expect(details.open).toBe(true);
|
|
||||||
expect(details.textContent).toContain("because");
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".thinking")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("streaming bubble shows the caret", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TypingIndicator", () => {
|
describe("SpawnView connect flow", () => {
|
||||||
it("renders three dots with aria-live", () => {
|
it("requires a token", async () => {
|
||||||
const { container } = render(<TypingIndicator />);
|
mockFetchJson((url) => {
|
||||||
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
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("ChatStream", () => {
|
describe("SpawnView repo picker", () => {
|
||||||
function stream(p: {
|
function connectedMock(): void {
|
||||||
messages: ChatMessage[];
|
mockFetchJson((url) => {
|
||||||
busy: boolean;
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
hasOlder?: boolean;
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
loadingOlder?: boolean;
|
if (url.endsWith("/api/gitlab/repos"))
|
||||||
onOlder?: () => void;
|
return [repo("g/alpha"), repo("g/beta", "dev")];
|
||||||
}): React.ReactElement {
|
return [];
|
||||||
return (
|
});
|
||||||
<ChatStream
|
|
||||||
messages={p.messages}
|
|
||||||
tools={new Map()}
|
|
||||||
busy={p.busy}
|
|
||||||
hasOlder={p.hasOlder ?? false}
|
|
||||||
loadingOlder={p.loadingOlder ?? false}
|
|
||||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
||||||
const { container, rerender } = render(
|
connectedMock();
|
||||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
|
render(tree(makeStore()));
|
||||||
);
|
await flush();
|
||||||
expect(container.querySelector(".typing")).not.toBeNull();
|
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
const filter = screen.getByLabelText("Filter repositories");
|
||||||
stream({
|
fireEvent.input(filter, { target: { value: "beta" } });
|
||||||
messages: [
|
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||||
msg({ key: "a", text: "one" }),
|
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||||
msg({ key: "b", streaming: true }),
|
|
||||||
],
|
const item = screen
|
||||||
busy: true,
|
.getByText("g/beta")
|
||||||
}),
|
.closest(".repo-item") as HTMLElement;
|
||||||
);
|
fireEvent.keyDown(item, { key: "Enter" });
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||||
rerender(
|
|
||||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
fireEvent.input(filter, { target: { value: "zzz" } });
|
||||||
);
|
expect(screen.getByText("no matching repos")).toBeInTheDocument();
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
it("keyboard Enter and Space on a repo item are default-prevented (C)", async () => {
|
||||||
const { container, rerender } = render(
|
connectedMock();
|
||||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
render(tree(makeStore()));
|
||||||
);
|
await flush();
|
||||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
const item = screen
|
||||||
Object.defineProperty(scroller, "scrollHeight", {
|
.getByText("g/alpha")
|
||||||
configurable: true,
|
.closest(".repo-item") as HTMLElement;
|
||||||
value: 1000,
|
|
||||||
});
|
|
||||||
Object.defineProperty(scroller, "clientHeight", {
|
|
||||||
configurable: true,
|
|
||||||
value: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
const enter = fireEvent.keyDown(item, { key: "Enter" });
|
||||||
// pinned: scrollTop at bottom
|
expect(enter).toBe(false); // preventDefault consumed: no page scroll
|
||||||
scroller.scrollTop = 700;
|
expect(screen.getByLabelText("Branch")).toHaveValue("main");
|
||||||
Object.defineProperty(scroller, "scrollTop", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: 700,
|
|
||||||
});
|
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
|
||||||
rerender(
|
|
||||||
stream({
|
|
||||||
messages: [msg({ key: "a" }), msg({ key: "b" })],
|
|
||||||
busy: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(scroller.scrollTop).toBe(1000);
|
|
||||||
|
|
||||||
// scroll far up -> unpin
|
const space = fireEvent.keyDown(item, { key: " " });
|
||||||
Object.defineProperty(scroller, "scrollTop", {
|
expect(space).toBe(false);
|
||||||
configurable: true,
|
expect(item.getAttribute("aria-pressed")).toBe("true"); // still picks
|
||||||
writable: true,
|
|
||||||
value: 0,
|
|
||||||
});
|
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
|
||||||
rerender(
|
|
||||||
stream({
|
|
||||||
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
|
|
||||||
busy: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(scroller.scrollTop).toBe(0);
|
|
||||||
|
|
||||||
// scroll near bottom (within 80px) -> pinned again
|
const tab = fireEvent.keyDown(item, { key: "Tab" });
|
||||||
Object.defineProperty(scroller, "scrollTop", {
|
expect(tab).toBe(true); // other keys keep their default behavior
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: 940,
|
|
||||||
});
|
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
|
||||||
rerender(
|
|
||||||
stream({
|
|
||||||
messages: [
|
|
||||||
msg({ key: "a" }),
|
|
||||||
msg({ key: "b" }),
|
|
||||||
msg({ key: "c" }),
|
|
||||||
msg({ key: "d" }),
|
|
||||||
],
|
|
||||||
busy: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(scroller.scrollTop).toBe(1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("load-older button renders only when an older page exists and fires on click", () => {
|
it("shows loading repos while the list is pending", async () => {
|
||||||
const onOlder = vi.fn();
|
const gate = { resolve: null as ((v: unknown) => void) | null };
|
||||||
const { rerender } = render(
|
mockFetchJson((url) => {
|
||||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
);
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
expect(
|
if (url.endsWith("/api/gitlab/repos"))
|
||||||
screen.queryByRole("button", { name: "Load older messages" }),
|
return new Promise((res) => {
|
||||||
).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
rerender(
|
it("spawn without selection shows pick-a-repo error", async () => {
|
||||||
stream({
|
connectedMock();
|
||||||
messages: [msg({ key: "a" })],
|
render(tree(makeStore()));
|
||||||
busy: false,
|
await flush();
|
||||||
hasOlder: true,
|
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
||||||
onOlder,
|
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
||||||
}),
|
|
||||||
);
|
|
||||||
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("copy button", () => {
|
describe("SpawnView spawn+poll", () => {
|
||||||
it("copy button writes message text and flashes copied", async () => {
|
beforeEach(() => {
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
vi.useFakeTimers();
|
||||||
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", () => {
|
it("spawns, polls until online, then navigates to the chat", async () => {
|
||||||
const { rerender } = render(
|
const posts: Array<[string, RequestInit | undefined]> = [];
|
||||||
<Bubble
|
let sessionsOnline = false; // flipped after the first poll tick
|
||||||
msg={{
|
const fetchMock = mockFetchJson((url, init) => {
|
||||||
key: "u",
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
role: "user",
|
posts.push([url, init]);
|
||||||
text: "hi",
|
return { sessionId: "new-1", containerId: "abc123def456" };
|
||||||
thinking: null,
|
}
|
||||||
toolCalls: [],
|
if (url.endsWith("/api/sessions"))
|
||||||
toolCallId: null,
|
return sessionsOnline
|
||||||
streaming: false,
|
? [
|
||||||
}}
|
{
|
||||||
tools={new Map()}
|
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(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Copy message" }),
|
screen.getByText("waiting for session to come online…"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
rerender(
|
|
||||||
<Bubble
|
// first tick: session not online yet
|
||||||
msg={{
|
await act(async () => {
|
||||||
key: "t",
|
vi.advanceTimersByTime(1500);
|
||||||
role: "toolResult",
|
});
|
||||||
text: "r",
|
await flush();
|
||||||
thinking: null,
|
expect(screen.queryByTestId("chat-route")).toBeNull();
|
||||||
toolCalls: [],
|
|
||||||
toolCallId: "c1",
|
// each poll tick issues exactly one sessions fetch (refresh reuse, S7)
|
||||||
streaming: false,
|
const sessionsFetches = fetchMock.mock.calls.filter(([u]) =>
|
||||||
}}
|
String(u).endsWith("/api/sessions"),
|
||||||
tools={new Map()}
|
).length;
|
||||||
/>,
|
expect(sessionsFetches).toBe(1);
|
||||||
);
|
|
||||||
expect(screen.queryByRole("button", { name: "Copy message" })).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 () => {
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
|
return { sessionId: "sx", containerId: "" };
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const store = makeStore({
|
||||||
|
spawnJobs: [
|
||||||
|
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(tree(store));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
|
bodies.push(String(init.body));
|
||||||
|
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const failingRefresh = makeStore({
|
||||||
|
refresh: (): Promise<SessionListItem[]> =>
|
||||||
|
Promise.reject(new Error("boom")),
|
||||||
|
});
|
||||||
|
render(tree(failingRefresh));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/blank"));
|
||||||
|
fireEvent.change(screen.getByLabelText("Branch"), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawn POST failure shows the error", async () => {
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
|
return jsonResponse({ error: "no docker" }, 500);
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/x"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("no docker")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling gives up after the tick cap and stays on the page", async () => {
|
||||||
|
let statusCalls = 0;
|
||||||
|
mockFetchJson((url) => {
|
||||||
|
if (url.endsWith("/api/spawn/status")) {
|
||||||
|
statusCalls += 1;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
||||||
|
if (url.endsWith("/api/spawn"))
|
||||||
|
return { sessionId: "slow-1", containerId: "d" };
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const { unmount } = render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/slow"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500 * 402);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
const afterCap = statusCalls;
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500 * 10);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(statusCalls).toBe(afterCap);
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawn job line renders from store.spawnJobs", async () => {
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
|
return { sessionId: "sj-1", containerId: "cid" };
|
||||||
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const store = makeStore();
|
||||||
|
const { rerender } = render(tree(store));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
|
||||||
|
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
||||||
|
act(() => {
|
||||||
|
rerender(tree(store));
|
||||||
|
});
|
||||||
|
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SpawnSteps", () => {
|
||||||
|
it("renders progress steps matching job state", async () => {
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
|
return { sessionId: "sp1", containerId: "" };
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const store = makeStore({
|
||||||
|
spawnJobs: [
|
||||||
|
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(tree(store));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
const steps = document.querySelectorAll(".spawn-progress .step");
|
||||||
|
expect(steps).toHaveLength(4);
|
||||||
|
expect(steps[0]?.className).toContain("done");
|
||||||
|
expect(steps[1]?.className).toContain("current");
|
||||||
|
expect(steps[2]?.className).toBe("step");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("progress element carries progressbar semantics for the current step", async () => {
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
|
return { sessionId: "sp2", containerId: "" };
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const store = makeStore({
|
||||||
|
spawnJobs: [
|
||||||
|
{ sessionId: "sp2", 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 bar = screen.getByRole("progressbar");
|
||||||
|
expect(bar).toHaveAttribute("aria-valuemin", "1");
|
||||||
|
expect(bar).toHaveAttribute("aria-valuemax", "4");
|
||||||
|
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -270,7 +270,11 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
aria-pressed={selected?.path === r.path}
|
aria-pressed={selected?.path === r.path}
|
||||||
onClick={() => pick(r)}
|
onClick={() => pick(r)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") pick(r);
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
// role=button: keep Space from scrolling the page
|
||||||
|
e.preventDefault();
|
||||||
|
pick(r);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ describe("TaskPanel", () => {
|
|||||||
expect(deleted.style.textDecoration).toContain("line-through");
|
expect(deleted.style.textDecoration).toContain("line-through");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("duplicate todo contents render as distinct rows (D)", () => {
|
||||||
|
const tasks: TaskDerivation = {
|
||||||
|
...base,
|
||||||
|
todos: [
|
||||||
|
{ content: "same text", status: "pending", deleted: false },
|
||||||
|
{ content: "same text", status: "completed", deleted: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { container } = render(<TaskPanel tasks={tasks} />);
|
||||||
|
// identical content would collide on key={content}; index-composite keys
|
||||||
|
// keep both rows
|
||||||
|
expect(container.querySelectorAll(".todo-item")).toHaveLength(2);
|
||||||
|
expect(container.querySelectorAll(".todo-icon")).toHaveLength(2);
|
||||||
|
expect(container.querySelector(".todo-icon.completed")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("subagent rows: running spinner, done check, failed cross", () => {
|
it("subagent rows: running spinner, done check, failed cross", () => {
|
||||||
const tasks: TaskDerivation = {
|
const tasks: TaskDerivation = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
+73
-64
@@ -2,78 +2,87 @@ import type { SubagentRun, TodoItem, TodoStatus } from "./derive";
|
|||||||
import type { TaskDerivation } from "./derive";
|
import type { TaskDerivation } from "./derive";
|
||||||
|
|
||||||
const STATUS_ICON: Record<TodoStatus, string> = {
|
const STATUS_ICON: Record<TodoStatus, string> = {
|
||||||
pending: "○",
|
pending: "○",
|
||||||
"in-progress": "◺",
|
"in-progress": "◺",
|
||||||
completed: "●",
|
completed: "●",
|
||||||
};
|
};
|
||||||
|
|
||||||
function TodoRow({ item }: { item: TodoItem }) {
|
function TodoRow({ item }: { item: TodoItem }) {
|
||||||
return (
|
return (
|
||||||
<div className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}>
|
<div
|
||||||
<span className={`todo-icon ${item.status}`} aria-hidden="true">
|
className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}
|
||||||
{STATUS_ICON[item.status]}
|
>
|
||||||
</span>
|
<span className={`todo-icon ${item.status}`} aria-hidden="true">
|
||||||
<span className="todo-text" style={item.deleted ? { textDecoration: "line-through", color: "var(--text-faint)" } : undefined}>
|
{STATUS_ICON[item.status]}
|
||||||
{item.content}
|
</span>
|
||||||
</span>
|
<span
|
||||||
</div>
|
className="todo-text"
|
||||||
);
|
style={
|
||||||
|
item.deleted
|
||||||
|
? { textDecoration: "line-through", color: "var(--text-faint)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubagentRow({ run }: { run: SubagentRun }) {
|
function SubagentRow({ run }: { run: SubagentRun }) {
|
||||||
return (
|
return (
|
||||||
<div className="subagent-item">
|
<div className="subagent-item">
|
||||||
{run.running ? (
|
{run.running ? (
|
||||||
<span className="spinner" role="status" aria-label="running" />
|
<span className="spinner" role="status" aria-label="running" />
|
||||||
) : (
|
) : (
|
||||||
<span className="done-icon" aria-hidden="true">
|
<span className="done-icon" aria-hidden="true">
|
||||||
{run.isError ? "✕" : "✓"}
|
{run.isError ? "✕" : "✓"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>{run.name}</span>
|
<span>{run.name}</span>
|
||||||
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
|
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
|
||||||
{run.running ? "running" : run.isError ? "failed" : "done"}
|
{run.running ? "running" : run.isError ? "failed" : "done"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
|
export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
|
||||||
const hasTodos: boolean = tasks.todos.length > 0;
|
const hasTodos: boolean = tasks.todos.length > 0;
|
||||||
const hasSubagents: boolean = tasks.subagents.length > 0;
|
const hasSubagents: boolean = tasks.subagents.length > 0;
|
||||||
const hasWorking: boolean = tasks.workingTools.length > 0;
|
const hasWorking: boolean = tasks.workingTools.length > 0;
|
||||||
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
|
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task-panel">
|
<div className="task-panel">
|
||||||
{empty && <p className="empty">No tasks yet.</p>}
|
{empty && <p className="empty">No tasks yet.</p>}
|
||||||
{hasTodos && (
|
{hasTodos && (
|
||||||
<section className="task-section">
|
<section className="task-section">
|
||||||
<h2>Tasks</h2>
|
<h2>Tasks</h2>
|
||||||
{tasks.todos.map((t) => (
|
{tasks.todos.map((t, i) => (
|
||||||
<TodoRow key={t.content} item={t} />
|
<TodoRow key={`${i}-${t.content}`} item={t} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{hasSubagents && (
|
{hasSubagents && (
|
||||||
<section className="task-section">
|
<section className="task-section">
|
||||||
<h2>Subagents</h2>
|
<h2>Subagents</h2>
|
||||||
{tasks.subagents.map((s) => (
|
{tasks.subagents.map((s) => (
|
||||||
<SubagentRow key={s.key} run={s} />
|
<SubagentRow key={s.key} run={s} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{hasWorking && (
|
{hasWorking && (
|
||||||
<section className="task-section">
|
<section className="task-section">
|
||||||
<h2>Working</h2>
|
<h2>Working</h2>
|
||||||
{tasks.workingTools.map((w) => (
|
{tasks.workingTools.map((w) => (
|
||||||
<div key={w.id} className="working-line">
|
<div key={w.id} className="working-line">
|
||||||
<span className="spinner" role="status" aria-label="working" />
|
<span className="spinner" role="status" aria-label="working" />
|
||||||
{w.name}…
|
{w.name}…
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-11
@@ -69,10 +69,28 @@ function listEvent(seq: number): EventFrame {
|
|||||||
describe("useSessions", () => {
|
describe("useSessions", () => {
|
||||||
it("returns null when unconfigured", () => {
|
it("returns null when unconfigured", () => {
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
expect(result.current).toBeNull();
|
expect(result.current).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("first-run: inactive -> active starts the ws manager (A)", async () => {
|
||||||
|
seedSettings();
|
||||||
|
mockFetchJson(() => []);
|
||||||
|
const push = vi.fn();
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ active }: { active: boolean }) => useSessions(push, active),
|
||||||
|
{ initialProps: { active: false } },
|
||||||
|
);
|
||||||
|
// gate is up: settings exist but the hook must not dial yet
|
||||||
|
expect(FakeWebSocket.instances.length).toBe(0);
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
|
||||||
|
// gate saved: active flips true, the effect re-runs and starts the manager
|
||||||
|
rerender({ active: true });
|
||||||
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
|
expect(result.current?.state).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
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: SessionListItem[] = [
|
const sessions: SessionListItem[] = [
|
||||||
@@ -88,7 +106,7 @@ describe("useSessions", () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
|
|
||||||
expect(result.current?.state).toBe("connecting");
|
expect(result.current?.state).toBe("connecting");
|
||||||
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
||||||
@@ -131,7 +149,7 @@ describe("useSessions", () => {
|
|||||||
throw new Error("network down");
|
throw new Error("network down");
|
||||||
});
|
});
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
||||||
);
|
);
|
||||||
@@ -147,7 +165,7 @@ describe("useSessions", () => {
|
|||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
|
|
||||||
@@ -198,7 +216,7 @@ describe("useSessions", () => {
|
|||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
act(() => sock.serverClose(1008));
|
act(() => sock.serverClose(1008));
|
||||||
@@ -219,7 +237,7 @@ describe("useSessions", () => {
|
|||||||
);
|
);
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
renderHook(() => useSessions(push));
|
renderHook(() => useSessions(push, true));
|
||||||
const failConnect = (): void => {
|
const failConnect = (): void => {
|
||||||
act(() => FakeWebSocket.last().serverClose(1006));
|
act(() => FakeWebSocket.last().serverClose(1006));
|
||||||
};
|
};
|
||||||
@@ -254,7 +272,7 @@ describe("useSessions", () => {
|
|||||||
});
|
});
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
renderHook(() => useSessions(push));
|
renderHook(() => useSessions(push, true));
|
||||||
const failConnect = (): void => {
|
const failConnect = (): void => {
|
||||||
act(() => FakeWebSocket.last().serverClose(1006));
|
act(() => FakeWebSocket.last().serverClose(1006));
|
||||||
};
|
};
|
||||||
@@ -293,7 +311,7 @@ describe("useSessions", () => {
|
|||||||
});
|
});
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
renderHook(() => useSessions(push));
|
renderHook(() => useSessions(push, true));
|
||||||
const failConnect = (): void => {
|
const failConnect = (): void => {
|
||||||
act(() => FakeWebSocket.last().serverClose(1006));
|
act(() => FakeWebSocket.last().serverClose(1006));
|
||||||
};
|
};
|
||||||
@@ -336,7 +354,7 @@ describe("useSessions", () => {
|
|||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { unmount } = renderHook(() => useSessions(push));
|
const { unmount } = renderHook(() => useSessions(push, true));
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -350,7 +368,7 @@ describe("useSessions", () => {
|
|||||||
const stable: SessionListItem[] = [];
|
const stable: SessionListItem[] = [];
|
||||||
mockFetchJson(() => stable);
|
mockFetchJson(() => stable);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result, rerender } = renderHook(() => useSessions(push));
|
const { result, rerender } = renderHook(() => useSessions(push, true));
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
// let the mount-time seed settle
|
// let the mount-time seed settle
|
||||||
for (let i = 0; i < 4; i += 1) {
|
for (let i = 0; i < 4; i += 1) {
|
||||||
@@ -370,7 +388,7 @@ describe("useSessions", () => {
|
|||||||
let list: SessionListItem[] = [];
|
let list: SessionListItem[] = [];
|
||||||
mockFetchJson(() => list);
|
mockFetchJson(() => list);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push, true));
|
||||||
await waitFor(() => expect(result.current).not.toBeNull());
|
await waitFor(() => expect(result.current).not.toBeNull());
|
||||||
const first = result.current;
|
const first = result.current;
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
|
|||||||
+7
-1
@@ -71,8 +71,13 @@ export interface SessionsStore {
|
|||||||
) => () => void;
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `active` tells the hook the settings gate has been passed; it must be a
|
||||||
|
* dep below so saving settings (false -> true) starts the ws manager.
|
||||||
|
*/
|
||||||
export function useSessions(
|
export function useSessions(
|
||||||
pushToast: (text: string) => void,
|
pushToast: (text: string) => void,
|
||||||
|
active: boolean,
|
||||||
): SessionsStore | null {
|
): SessionsStore | null {
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||||
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
|
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
|
||||||
@@ -80,6 +85,7 @@ export function useSessions(
|
|||||||
const [manager, setManager] = useState<WsManager | null>(null);
|
const [manager, setManager] = useState<WsManager | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
if (settings === null) return;
|
if (settings === null) return;
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ export function useSessions(
|
|||||||
m.close();
|
m.close();
|
||||||
setManager(null);
|
setManager(null);
|
||||||
};
|
};
|
||||||
}, [pushToast]);
|
}, [pushToast, active]);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
|
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user