Queue mid-turn messages for the next tool call; remove all tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user