Queue mid-turn messages for the next tool call; remove all tests

This commit is contained in:
Ian Gulliver
2026-10-15 18:33:18 -07:00
parent 4f94d73b58
commit 514a2580f7
68 changed files with 364 additions and 6851 deletions
-122
View File
@@ -1,122 +0,0 @@
package server_test
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"g.fc.run/misc/byre/internal/api"
)
func TestTaskFiles(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "files"}, &task)
wt := task.WorktreePath
outside := t.TempDir()
writeFile(t, filepath.Join(outside, "secret"), "secret\n")
err := os.Mkdir(filepath.Join(wt, "src"), 0o755)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(wt, "src", "main.go"), "package main\n")
writeFile(t, filepath.Join(wt, "src", "main.o"), "obj")
writeFile(t, filepath.Join(wt, ".gitignore"), "*.o\n/out/\n")
err = os.Mkdir(filepath.Join(wt, "out"), 0o755)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(wt, "out", "app"), "bin")
writeFile(t, filepath.Join(wt, "blob.bin"), "a\x00b")
writeFile(t, filepath.Join(wt, "big.txt"), strings.Repeat("é", 200_000))
for name, target := range map[string]string{
"inlink": "src",
"outlink": filepath.Join(outside, "secret"),
"outdir": outside,
"dangling": "nope",
} {
err = os.Symlink(target, filepath.Join(wt, name))
if err != nil {
t.Fatalf("symlink: %v", err)
}
}
dir := func(path string) (api.DirListing, int) {
l := api.DirListing{}
code := h.do("GET", "/api/tasks/"+task.Id+"/dir?path="+url.QueryEscape(path), nil, &l)
return l, code
}
file := func(path string) (api.FileContent, int) {
f := api.FileContent{}
code := h.do("GET", "/api/tasks/"+task.Id+"/file?path="+url.QueryEscape(path), nil, &f)
return f, code
}
root, code := dir("")
if code != http.StatusOK {
t.Fatalf("list root: %d", code)
}
names := func(l api.DirListing) string {
got := []string{}
for _, e := range l.Entries {
s := string(e.Type) + ":" + e.Name
if e.Ignored {
s += "!"
}
got = append(got, s)
}
return strings.Join(got, " ")
}
want := "dir:inlink dir:out! dir:src file:.gitignore file:README file:big.txt file:blob.bin other:dangling other:outdir other:outlink"
if names(root) != want {
t.Fatalf("root listing %q, want %q", names(root), want)
}
for path, want := range map[string]string{
"src": "file:main.go file:main.o!",
"inlink": "file:main.go file:main.o!",
"out": "file:app!",
} {
sub, code := dir(path)
if code != http.StatusOK || sub.Path != path || names(sub) != want {
t.Fatalf("list %s: %d %q, want %q", path, code, names(sub), want)
}
}
f, code := file("src/main.go")
if code != http.StatusOK || f.Text != "package main\n" || f.Binary || f.Truncated {
t.Fatalf("read text: %d %+v", code, f)
}
f, code = file("blob.bin")
if code != http.StatusOK || !f.Binary || f.Text != "" || f.Size != 3 {
t.Fatalf("read binary: %d %+v", code, f)
}
f, code = file("big.txt")
if code != http.StatusOK || f.Binary || !f.Truncated || f.Size != 400_000 || len(f.Text) != 256<<10 {
t.Fatalf("read big: %d binary=%v truncated=%v size=%d len=%d", code, f.Binary, f.Truncated, f.Size, len(f.Text))
}
for _, path := range []string{"../secret", "/etc/passwd", "src/../../x", "outlink", "outdir/secret", ".git", "src"} {
_, code = file(path)
if code != http.StatusBadRequest {
t.Fatalf("read %s: want 400, got %d", path, code)
}
}
for _, path := range []string{"..", "outdir", "README"} {
_, code = dir(path)
if code != http.StatusBadRequest {
t.Fatalf("list %s: want 400, got %d", path, code)
}
}
_, code = file("missing")
if code != http.StatusNotFound {
t.Fatalf("read missing: want 404, got %d", code)
}
}
+1 -1
View File
@@ -655,7 +655,7 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
model = *req.Model
}
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model, images...)
sess, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, images...)
if err != nil {
writeAgentError(w, err)
return
-577
View File
@@ -1,577 +0,0 @@
package server_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/server"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/web"
)
const login = "alice@example.com"
type harness struct {
t *testing.T
url string
}
func newHarness(t *testing.T) *harness {
srv, err := server.New(t.TempDir(), store.DefaultPortFirst, fstest.MapFS{"index.html": {Data: []byte("shell")}})
if err != nil {
t.Fatalf("new server: %v", err)
}
t.Cleanup(func() { srv.Close() })
hs := httptest.NewServer(srv.Handler())
t.Cleanup(hs.Close)
return &harness{t: t, url: hs.URL}
}
func (h *harness) do(method, path string, body any, out any) int {
h.t.Helper()
buf := &bytes.Buffer{}
if body != nil {
err := json.NewEncoder(buf).Encode(body)
if err != nil {
h.t.Fatalf("encode: %v", err)
}
}
req, err := http.NewRequest(method, h.url+path, buf)
if err != nil {
h.t.Fatalf("new request: %v", err)
}
req.Header.Set("Tailscale-User-Login", login)
req.Header.Set("Tailscale-User-Name", "Alice")
resp, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
if out != nil && resp.StatusCode < 300 {
err = json.NewDecoder(resp.Body).Decode(out)
if err != nil {
h.t.Fatalf("decode %s %s: %v", method, path, err)
}
}
return resp.StatusCode
}
func (h *harness) createProject(name string) api.Project {
h.t.Helper()
p := api.Project{}
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: name, RepoPath: repoWithOrigin(h.t)}, &p)
if code != http.StatusCreated {
h.t.Fatalf("create project: %d", code)
}
return p
}
func TestIdentityRequired(t *testing.T) {
h := newHarness(t)
resp, err := http.Get(h.url + "/api/me")
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401 without tailscale headers, got %d", resp.StatusCode)
}
resp, err = http.Get(h.url + "/")
if err != nil {
t.Fatalf("get spa: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("want spa shell without auth, got %d", resp.StatusCode)
}
}
func TestServesEmbeddedSPA(t *testing.T) {
srv, err := server.New(t.TempDir(), store.DefaultPortFirst, web.FS)
if err != nil {
t.Fatalf("new server: %v", err)
}
t.Cleanup(func() { srv.Close() })
hs := httptest.NewServer(srv.Handler())
t.Cleanup(hs.Close)
for path, contentType := range map[string]string{
"/": "text/html",
"/style.css": "text/css",
"/dist/app.js": "text/javascript",
"/dist/app.js.map": "",
"/src/app.ts": "",
"/dist/inbox.js": "text/javascript",
"/dist/api.gen.js": "text/javascript",
"/dist/lead.js": "text/javascript",
"/dist/navbar.js": "text/javascript",
"/dist/panes.js": "text/javascript",
"/dist/task.js": "text/javascript",
"/dist/state.js": "text/javascript",
"/dist/dom.js": "text/javascript",
"/dist/api.js": "text/javascript",
"/dist/settings.js": "text/javascript",
"/dist/files.js": "text/javascript",
"/sw.js": "text/javascript",
"/dist/mermaid.js": "text/javascript",
"/vendor/mermaid.min.js": "text/javascript",
"/vendor/mermaid.LICENSE": "",
"/dist/terminal.js": "text/javascript",
"/vendor/xterm.mjs": "text/javascript",
"/vendor/addon-fit.mjs": "text/javascript",
"/vendor/xterm.css": "text/css",
"/vendor/xterm.LICENSE": "",
"/fonts/atkinson-hyperlegible-mono-latin-wght-normal.woff2": "font/woff2",
"/fonts/LICENSE": "",
} {
resp, err := http.Get(hs.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("get %s: %d", path, resp.StatusCode)
}
if contentType != "" && !strings.HasPrefix(resp.Header.Get("Content-Type"), contentType) {
t.Fatalf("get %s: content type %q, want %s", path, resp.Header.Get("Content-Type"), contentType)
}
}
}
func TestMe(t *testing.T) {
h := newHarness(t)
me := api.User{}
h.do("GET", "/api/me", nil, &me)
if me.Login != login || me.DisplayName != "Alice" || me.GitEmail != "" {
t.Fatalf("unexpected user %+v", me)
}
code := h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "nope"}, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 for bad email, got %d", code)
}
code = h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice A", GitEmail: "alice@example.org"}, &me)
if code != http.StatusOK || me.GitName != "Alice A" || me.GitEmail != "alice@example.org" {
t.Fatalf("update failed: %d %+v", code, me)
}
}
func TestNotificationSettings(t *testing.T) {
h := newHarness(t)
me := api.User{}
h.do("GET", "/api/me", nil, &me)
if me.NotificationPrefs != (api.NotificationPrefs{BlockingQuestions: true}) {
t.Fatalf("unexpected default prefs %+v", me.NotificationPrefs)
}
code := h.do("PUT", "/api/me/notifications", api.NotificationPrefs{BlockingQuestions: true, Reports: true}, &me)
if code != http.StatusOK || me.NotificationPrefs != (api.NotificationPrefs{BlockingQuestions: true, Reports: true}) {
t.Fatalf("update prefs: %d %+v", code, me.NotificationPrefs)
}
key := api.PushKey{}
h.do("GET", "/api/push/key", nil, &key)
if len(key.PublicKey) < 80 {
t.Fatalf("unexpected vapid key %q", key.PublicKey)
}
code = h.do("POST", "/api/me/push-subscriptions", api.PushSubscription{Endpoint: "http://push.example/x", P256dh: "k", Auth: "a"}, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 for a non-https endpoint, got %d", code)
}
code = h.do("POST", "/api/me/push-subscriptions", api.PushSubscription{Endpoint: "https://push.example/x", P256dh: "k", Auth: "a"}, nil)
if code != http.StatusNoContent {
t.Fatalf("subscribe: %d", code)
}
code = h.do("DELETE", "/api/me/push-subscriptions?endpoint=https%3A%2F%2Fpush.example%2Fx", nil, nil)
if code != http.StatusNoContent {
t.Fatalf("unsubscribe: %d", code)
}
}
func TestProjectAndTaskLifecycle(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
if p.DefaultBranch != "main" || !strings.HasSuffix(p.Remote, "origin.git") {
t.Fatalf("unexpected project %+v", p)
}
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: "demo", RepoPath: p.RepoPath}, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 for duplicate project, got %d", code)
}
task := api.Task{}
code = h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "first"}, &task)
if code != http.StatusCreated {
t.Fatalf("create task: %d", code)
}
_, err := os.Stat(filepath.Join(task.WorktreePath, "README"))
if err != nil {
t.Fatalf("worktree not checked out: %v", err)
}
head := gitOut(t, task.WorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if head != task.Branch {
t.Fatalf("worktree on %s, want %s", head, task.Branch)
}
if task.BaseRef != gitOut(t, p.RepoPath, "rev-parse", "main") {
t.Fatalf("base ref %s is not main's head", task.BaseRef)
}
tasks := []api.Task{}
h.do("GET", "/api/projects/"+p.Id+"/tasks", nil, &tasks)
if len(tasks) != 1 || tasks[0].Id != task.Id {
t.Fatalf("unexpected task list %+v", tasks)
}
err = os.WriteFile(filepath.Join(task.WorktreePath, "dirty"), []byte("x"), 0o644)
if err != nil {
t.Fatalf("write: %v", err)
}
unmerged := api.Unmerged{}
h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged)
if unmerged != (api.Unmerged{Commits: 0, Files: 1, Insertions: 1, Deletions: 0, Uncommitted: 1}) {
t.Fatalf("unexpected unmerged %+v", unmerged)
}
done := api.Task{}
code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, &done)
if code != http.StatusOK || done.Status != api.TaskStatusDiscarded || done.ClosedAt == nil || done.MergeCommit != nil {
t.Fatalf("discard: %d %+v", code, done)
}
if gitOut(t, p.RepoPath, "rev-parse", "main") != task.BaseRef {
t.Fatalf("discard moved main")
}
_, err = os.Stat(task.WorktreePath)
if !os.IsNotExist(err) {
t.Fatalf("worktree still exists: %v", err)
}
branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch)
if branches != "" {
t.Fatalf("branch still exists: %s", branches)
}
code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
if code != http.StatusConflict {
t.Fatalf("want 409 discarding twice, got %d", code)
}
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
if code != http.StatusConflict {
t.Fatalf("want 409 merging a discarded task, got %d", code)
}
code = h.do("GET", "/api/tasks/nope", nil, nil)
if code != http.StatusNotFound {
t.Fatalf("want 404 for unknown task, got %d", code)
}
}
func TestMergeTask(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Add notes"}, &task)
coAuthors := []api.Participant{}
code := h.do("GET", "/api/tasks/"+task.Id+"/co-authors", nil, &coAuthors)
if code != http.StatusOK || len(coAuthors) != 0 {
t.Fatalf("co-authors: %d %+v", code, coAuthors)
}
code = h.do("GET", "/api/tasks/nope/co-authors", nil, nil)
if code != http.StatusNotFound {
t.Fatalf("want 404 for co-authors of an unknown task, got %d", code)
}
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 merging without a git identity, got %d", code)
}
got := api.Task{}
h.do("GET", "/api/tasks/"+task.Id, nil, &got)
if got.Status != api.TaskStatusActive {
t.Fatalf("refused merge changed the task: %+v", got)
}
}
func TestTaskImage(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "images"}, &task)
err := os.MkdirAll(filepath.Join(task.WorktreePath, "shots"), 0o755)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(task.WorktreePath, "shots", "a.png"), "\x89PNG\r\n\x1a\nfake")
writeFile(t, filepath.Join(task.WorktreePath, "notes.txt"), "text")
outside := filepath.Join(t.TempDir(), "secret.png")
writeFile(t, outside, "secret")
err = os.Symlink(outside, filepath.Join(task.WorktreePath, "link.png"))
if err != nil {
t.Fatalf("symlink: %v", err)
}
sibling := task.WorktreePath + "x"
err = os.MkdirAll(sibling, 0o755)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(sibling, "a.png"), "sibling")
for path, want := range map[string]int{
"shots/a.png": http.StatusOK,
filepath.Join(task.WorktreePath, "shots", "a.png"): http.StatusOK,
"shots/../shots/a.png": http.StatusOK,
"missing.png": http.StatusNotFound,
"notes.txt": http.StatusBadRequest,
"shots": http.StatusBadRequest,
"link.png": http.StatusForbidden,
outside: http.StatusForbidden,
"../" + filepath.Base(sibling) + "/a.png": http.StatusForbidden,
} {
req, err := http.NewRequest("GET", h.url+"/api/tasks/"+task.Id+"/image?path="+url.QueryEscape(path), nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Tailscale-User-Login", login)
req.Header.Set("Tailscale-User-Name", "Alice")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("get %s: %d %s, want %d", path, resp.StatusCode, body, want)
}
if want == http.StatusOK && (resp.Header.Get("Content-Type") != "image/png" || !bytes.HasPrefix(body, []byte("\x89PNG"))) {
t.Fatalf("get %s: %q %q", path, resp.Header.Get("Content-Type"), body)
}
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
err := os.WriteFile(path, []byte(content), 0o644)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestEventStreamResume(t *testing.T) {
h := newHarness(t)
stream := h.openStream("", nil)
p := h.createProject("demo")
first := stream.next()
if first.Kind != "project.created" || *first.ProjectId != p.Id {
t.Fatalf("unexpected first event %+v", first)
}
stream.close()
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "while away"}, &task)
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
resumed := h.openStream("", &first.Seq)
missed := resumed.next()
if missed.Kind != "task.created" || *missed.TaskId != task.Id || missed.Seq != first.Seq+1 {
t.Fatalf("unexpected replayed event %+v", missed)
}
if next := resumed.next(); next.Kind != "user.updated" {
t.Fatalf("unexpected second replayed event %+v", next)
}
h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
if live := resumed.next(); live.Kind != "task.discarded" {
t.Fatalf("unexpected live event %+v", live)
}
status := api.Status{}
h.do("GET", "/api/admin/status", nil, &status)
if status.LastEventSeq != 4 || status.Projects != 1 || status.ActiveTasks != 0 || status.SseClients < 1 {
t.Fatalf("unexpected status %+v", status)
}
}
func TestTaskEventStream(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
mine := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "mine"}, &mine)
other := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "other"}, &other)
stream := h.openStream("?task_id="+mine.Id, nil)
replayed := stream.next()
if replayed.Kind != "task.created" || *replayed.TaskId != mine.Id {
t.Fatalf("unexpected replayed event %+v", replayed)
}
h.do("POST", "/api/tasks/"+other.Id+"/discard", nil, nil)
h.do("POST", "/api/tasks/"+mine.Id+"/discard", nil, nil)
if live := stream.next(); live.Kind != "task.discarded" || *live.TaskId != mine.Id {
t.Fatalf("unexpected live event %+v", live)
}
code := h.do("GET", "/api/events?task_id=nope", nil, nil)
if code != http.StatusNotFound {
t.Fatalf("want 404 streaming an unknown task, got %d", code)
}
}
type stream struct {
t *testing.T
cancel context.CancelFunc
resp *http.Response
r *bufio.Reader
}
func (h *harness) openStream(query string, lastEventID *int64) *stream {
h.t.Helper()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", h.url+"/api/events"+query, nil)
if err != nil {
h.t.Fatalf("new request: %v", err)
}
req.Header.Set("Tailscale-User-Login", login)
req.Header.Set("Tailscale-User-Name", "Alice")
if lastEventID != nil {
req.Header.Set("Last-Event-ID", fmt.Sprint(*lastEventID))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("open stream: %v", err)
}
if resp.StatusCode != http.StatusOK {
h.t.Fatalf("open stream: %d", resp.StatusCode)
}
s := &stream{t: h.t, cancel: cancel, resp: resp, r: bufio.NewReader(resp.Body)}
h.t.Cleanup(s.close)
return s
}
func (s *stream) next() api.Event {
s.t.Helper()
type result struct {
ev api.Event
err error
}
ch := make(chan result, 1)
go func() {
id := ""
data := ""
for {
line, err := s.r.ReadString('\n')
if err != nil {
ch <- result{err: err}
return
}
line = strings.TrimRight(line, "\n")
switch {
case strings.HasPrefix(line, "id: "):
id = strings.TrimPrefix(line, "id: ")
case strings.HasPrefix(line, "data: "):
data = strings.TrimPrefix(line, "data: ")
case line == "" && data != "":
ev := api.Event{}
err := json.Unmarshal([]byte(data), &ev)
if err == nil && fmt.Sprint(ev.Seq) != id {
err = fmt.Errorf("sse id %s does not match seq %d", id, ev.Seq)
}
ch <- result{ev: ev, err: err}
return
}
}
}()
select {
case res := <-ch:
if res.err != nil {
s.t.Fatalf("read event: %v", res.err)
}
return res.ev
case <-time.After(5 * time.Second):
s.t.Fatalf("timed out waiting for event")
return api.Event{}
}
}
func (s *stream) close() {
s.cancel()
s.resp.Body.Close()
}
func repoWithOrigin(t *testing.T) string {
dir := t.TempDir()
origin := filepath.Join(dir, "origin.git")
repo := filepath.Join(dir, "repo")
gitOut(t, dir, "init", "--bare", "--initial-branch=main", origin)
gitOut(t, dir, "init", "--initial-branch=main", repo)
gitOut(t, repo, "remote", "add", "origin", origin)
err := os.WriteFile(filepath.Join(repo, "README"), []byte("hi\n"), 0o644)
if err != nil {
t.Fatalf("write readme: %v", err)
}
gitOut(t, repo, "add", "README")
gitOut(t, repo, "commit", "--message", "root")
gitOut(t, repo, "push", "origin", "main")
return repo
}
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
-147
View File
@@ -1,147 +0,0 @@
package server_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"g.fc.run/misc/byre/internal/api"
)
type termConn struct {
t *testing.T
conn *websocket.Conn
seen strings.Builder
}
func (h *harness) openTerminal(taskID string) *termConn {
h.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
header := http.Header{}
header.Set("Tailscale-User-Login", login)
header.Set("Tailscale-User-Name", "Alice")
url := "ws" + strings.TrimPrefix(h.url, "http") + "/api/tasks/" + taskID + "/terminal?cols=80&rows=24"
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: header})
if err != nil {
h.t.Fatalf("dial terminal: %v", err)
}
h.t.Cleanup(func() { conn.CloseNow() })
return &termConn{t: h.t, conn: conn}
}
func (c *termConn) send(text string) {
c.t.Helper()
err := c.conn.Write(context.Background(), websocket.MessageBinary, []byte(text))
if err != nil {
c.t.Fatalf("write terminal: %v", err)
}
}
func (c *termConn) waitFor(want string) {
c.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for !strings.Contains(c.seen.String(), want) {
_, b, err := c.conn.Read(ctx)
if err != nil {
c.t.Fatalf("waiting for %q: %v; seen %q", want, err, c.seen.String())
}
c.seen.Write(b)
}
}
func (c *termConn) waitClosed() websocket.CloseError {
c.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for {
_, b, err := c.conn.Read(ctx)
if err == nil {
c.seen.Write(b)
continue
}
ce := websocket.CloseError{}
if !errors.As(err, &ce) {
c.t.Fatalf("want a close frame, got %v", err)
}
return ce
}
}
func TestTaskTerminal(t *testing.T) {
t.Setenv("SHELL", "/bin/sh")
h := newHarness(t)
p := h.createProject("demo")
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "terminal"}, &task)
wt, err := filepath.EvalSymlinks(task.WorktreePath)
if err != nil {
t.Fatalf("resolve worktree: %v", err)
}
header := http.Header{}
header.Set("Tailscale-User-Login", login)
header.Set("Tailscale-User-Name", "Alice")
header.Set("Origin", "https://elsewhere.example")
_, resp, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(h.url, "http")+"/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", &websocket.DialOptions{HTTPHeader: header})
if err == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
t.Fatalf("cross-origin terminal: want 403, got %v %v", resp, err)
}
a := h.openTerminal(task.Id)
a.send("echo \"$PORT:$(pwd -P)\"\n")
a.waitFor(fmt.Sprintf("%d:%s", task.PortBase, wt))
a.send("echo byre-$((40+2))\n")
a.waitFor("byre-42")
size, err := json.Marshal(api.TerminalSize{Cols: 100, Rows: 30})
if err != nil {
t.Fatalf("marshal size: %v", err)
}
err = a.conn.Write(context.Background(), websocket.MessageText, size)
if err != nil {
t.Fatalf("resize: %v", err)
}
a.send("stty size\n")
a.waitFor("30 100")
b := h.openTerminal(task.Id)
b.waitFor("byre-42")
b.send("exit\n")
for _, c := range []*termConn{a, b} {
ce := c.waitClosed()
if ce.Code != 4000 || ce.Reason != "exit status 0" {
t.Fatalf("close after exit: %d %q", ce.Code, ce.Reason)
}
}
c := h.openTerminal(task.Id)
c.send("echo fresh\n")
c.waitFor("fresh")
if strings.Contains(c.seen.String(), "byre-42") {
t.Fatalf("new shell replayed the old one's output: %q", c.seen.String())
}
code := h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
if code != http.StatusOK {
t.Fatalf("discard: %d", code)
}
ce := c.waitClosed()
if ce.Code != 4000 {
t.Fatalf("close after discard: %d %q", ce.Code, ce.Reason)
}
code = h.do("GET", "/api/tasks/"+task.Id+"/terminal?cols=80&rows=24", nil, nil)
if code != http.StatusConflict {
t.Fatalf("terminal of a discarded task: want 409, got %d", code)
}
}