Add per-task terminals over WebSocket with an xterm.js Terminal tab

This commit is contained in:
Ian Gulliver
2026-10-15 12:06:50 -07:00
parent 37bf1b60a7
commit ff22f33025
33 changed files with 1600 additions and 167 deletions
+147
View File
@@ -0,0 +1,147 @@
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)
}
}