Add Go HTTP/SSE API server with embedded frontend
Implements the API routes (jobs, progress events, snapshots, search, entities, source delivery with release) to net/http and serves the static frontend from the same binary on the same origin.
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeliveryRangesMergeBeforeSourceIsReleased(t *testing.T) {
|
||||
var ranges [][2]int
|
||||
var done bool
|
||||
for _, step := range []struct {
|
||||
start, end int
|
||||
want bool
|
||||
}{{600, 1000, false}, {0, 400, false}, {350, 550, false}, {500, 700, true}} {
|
||||
if ranges, done = recordDelivery(ranges, step.start, step.end, 1000); done != step.want {
|
||||
t.Fatalf("recordDelivery(%d,%d) = %v, want %v", step.start, step.end, done, step.want)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(ranges, [][2]int{{0, 1000}}) {
|
||||
t.Fatalf("ranges = %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyOrDuplicateDeliveryDoesNotFakeCoverage(t *testing.T) {
|
||||
var ranges [][2]int
|
||||
var done bool
|
||||
for _, end := range []int{0, 5, 5} {
|
||||
if ranges, done = recordDelivery(ranges, 0, end, 10); done {
|
||||
t.Fatalf("coverage reported after [0,%d)", end)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(ranges, [][2]int{{0, 5}}) {
|
||||
t.Fatalf("ranges = %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubURLValidation(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://github.com/makepad/makepad": "https://github.com/makepad/makepad",
|
||||
" https://github.com/a/b.git/ ": "https://github.com/a/b",
|
||||
"https://example.com/a/b": "",
|
||||
"https://github.com/a/b/tree/main": "",
|
||||
"https://github.com/a/b;rm -rf": "",
|
||||
"https://github.com/-/--upload-pack=": "",
|
||||
} {
|
||||
if got := cleanGithubURL(in); got != want {
|
||||
t.Errorf("cleanGithubURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathValidation(t *testing.T) {
|
||||
for in, ok := range map[string]bool{"src/main.rs": true, "a/./b": true, "../secret": false, "a/../../b": false, "/etc/passwd": false, "./a": false, "": false, ".": false} {
|
||||
if _, got := safeRelativePath(in); got != ok {
|
||||
t.Errorf("safeRelativePath(%q) ok = %v, want %v", in, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
t *testing.T
|
||||
url string
|
||||
}
|
||||
|
||||
func (c client) do(method, path, body string) (int, []byte) {
|
||||
c.t.Helper()
|
||||
req, _ := http.NewRequest(method, c.url+path, strings.NewReader(body))
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, _ := io.ReadAll(res.Body)
|
||||
return res.StatusCode, data
|
||||
}
|
||||
|
||||
func (c client) json(method, path, body string, wantStatus int, out any) {
|
||||
c.t.Helper()
|
||||
status, data := c.do(method, path, body)
|
||||
if status != wantStatus {
|
||||
c.t.Fatalf("%s %s = %d %s, want %d", method, path, status, data, wantStatus)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
c.t.Fatalf("%s %s: %v in %s", method, path, err, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, client) {
|
||||
server := NewServer(filepath.Join(t.TempDir(), "codenavigator"))
|
||||
ts := httptest.NewServer(server)
|
||||
t.Cleanup(ts.Close)
|
||||
return server, client{t, ts.URL}
|
||||
}
|
||||
|
||||
func TestLocalJobLifecycle(t *testing.T) {
|
||||
server, c := newTestServer(t)
|
||||
var created struct{ JobID string }
|
||||
c.json("POST", "/api/jobs", `{"kind":"local","name":"demo"}`, 201, &created)
|
||||
|
||||
sources := map[string]string{
|
||||
"src/app.js": "import { helper } from './helper.js';\nfunction start() {\n helper();\n}\n",
|
||||
"src/helper.js": "export function helper() {\n return '<ok>';\n}\n",
|
||||
}
|
||||
for path, content := range sources {
|
||||
c.json("POST", "/api/jobs/"+created.JobID+"/files?path="+strings.ReplaceAll(path, "/", "%2F"), content, 200, nil)
|
||||
}
|
||||
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=..%2Fescape.js", "x", 400, nil)
|
||||
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
||||
|
||||
res, err := http.Get(c.url + "/api/jobs/" + created.JobID + "/events")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct := res.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Fatalf("events content type = %q", ct)
|
||||
}
|
||||
var job Job
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
for scanner.Scan() {
|
||||
if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok {
|
||||
job = Job{}
|
||||
json.Unmarshal([]byte(data), &job)
|
||||
}
|
||||
}
|
||||
res.Body.Close()
|
||||
if job.SnapshotID == nil || job.Phase != "ready" {
|
||||
t.Fatalf("final job event = %+v", job)
|
||||
}
|
||||
snapshotURL := "/api/snapshots/" + *job.SnapshotID
|
||||
|
||||
var summary map[string]any
|
||||
c.json("GET", snapshotURL, "", 200, &summary)
|
||||
if summary["fileCount"] != 2.0 || summary["references"] != 1.0 || summary["name"] != "demo" {
|
||||
t.Fatalf("summary = %v", summary)
|
||||
}
|
||||
|
||||
status, raw := c.do("GET", snapshotURL+"/scene", "")
|
||||
if status != 200 || !strings.Contains(string(raw), `'<ok>'`) {
|
||||
t.Fatalf("scene = %d %s (previews must not be HTML-escaped)", status, raw)
|
||||
}
|
||||
var scene struct {
|
||||
Files []sceneFile
|
||||
Edges []Edge
|
||||
}
|
||||
json.Unmarshal(raw, &scene)
|
||||
if len(scene.Files) != 2 || scene.Files[0].Path != "src/app.js" || scene.Files[0].SymbolCount != 1 || len(scene.Edges) != 1 {
|
||||
t.Fatalf("scene = %+v", scene)
|
||||
}
|
||||
if server.snapshots[*job.SnapshotID].Files[0].Preview != "" {
|
||||
t.Fatal("previews should be released after the scene is delivered")
|
||||
}
|
||||
|
||||
var hits []searchHit
|
||||
c.json("GET", snapshotURL+"/search?q=HELP", "", 200, &hits)
|
||||
if len(hits) != 2 || hits[0].Kind != "file" || hits[1].Name != "helper" || hits[1].Line != 1 {
|
||||
t.Fatalf("hits = %+v", hits)
|
||||
}
|
||||
c.json("GET", snapshotURL+"/search?q=h", "", 200, &hits)
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("single-character search returned %v", hits)
|
||||
}
|
||||
|
||||
var entity FileRecord
|
||||
c.json("GET", snapshotURL+"/entities/1", "", 200, &entity)
|
||||
if entity.Path != "src/app.js" || entity.Imports == nil || len(entity.Symbols) != 1 {
|
||||
t.Fatalf("entity = %+v", entity)
|
||||
}
|
||||
c.json("GET", snapshotURL+"/entities/99", "", 404, nil)
|
||||
c.json("GET", snapshotURL+"/entities/1/nope", "", 404, nil)
|
||||
|
||||
root := server.snapshots[*job.SnapshotID].Root
|
||||
var chunk struct {
|
||||
Start, TotalLines int
|
||||
Lines []string
|
||||
}
|
||||
c.json("GET", snapshotURL+"/entities/1/source?start=2&limit=5", "", 200, &chunk)
|
||||
if chunk.Start != 2 || chunk.TotalLines != 4 || !reflect.DeepEqual(chunk.Lines, []string{" helper();", "}"}) {
|
||||
t.Fatalf("chunk = %+v", chunk)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "src/app.js")); err != nil {
|
||||
t.Fatal("partially delivered source was released early")
|
||||
}
|
||||
c.json("GET", snapshotURL+"/entities/1/source?start=0&limit=2", "", 200, &chunk)
|
||||
if _, err := os.Stat(filepath.Join(root, "src/app.js")); !os.IsNotExist(err) {
|
||||
t.Fatal("fully delivered source should be deleted")
|
||||
}
|
||||
c.json("GET", snapshotURL+"/entities/2/source", "", 200, &chunk)
|
||||
if _, err := os.Stat(filepath.Dir(root)); !os.IsNotExist(err) {
|
||||
t.Fatal("workspace should be removed once every source is delivered")
|
||||
}
|
||||
c.json("GET", snapshotURL+"/entities/2/source", "", 404, nil)
|
||||
}
|
||||
|
||||
func TestRoutesAndHeaders(t *testing.T) {
|
||||
_, c := newTestServer(t)
|
||||
var health map[string]any
|
||||
c.json("GET", "/api/health", "", 200, &health)
|
||||
if health["ok"] != true {
|
||||
t.Fatalf("health = %v", health)
|
||||
}
|
||||
c.json("POST", "/api/jobs", `not json`, 400, nil)
|
||||
c.json("POST", "/api/jobs", `{"kind":"github"}`, 400, nil)
|
||||
c.json("POST", "/api/jobs", `{"kind":"github","url":"https://evil.example/a/b"}`, 400, nil)
|
||||
c.json("GET", "/api/jobs/missing", "", 404, nil)
|
||||
c.json("POST", "/api/jobs/missing/commit", "", 404, nil)
|
||||
c.json("GET", "/api/snapshots/missing/scene", "", 404, nil)
|
||||
c.json("GET", "/api/unknown", "", 404, nil)
|
||||
|
||||
res, err := http.DefaultClient.Do(must(http.NewRequest("OPTIONS", c.url+"/api/jobs", nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 204 || res.Header.Get("Access-Control-Allow-Origin") != "*" || !strings.Contains(res.Header.Get("Access-Control-Allow-Headers"), "ngrok-skip-browser-warning") {
|
||||
t.Fatalf("preflight = %d %v", res.StatusCode, res.Header)
|
||||
}
|
||||
|
||||
for path, contentType := range map[string]string{"/": "text/html", "/api.mjs": "text/javascript", "/styles.css": "text/css", "/config.js": "text/javascript"} {
|
||||
res, err := http.Get(c.url + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 || !strings.HasPrefix(res.Header.Get("Content-Type"), contentType) {
|
||||
t.Errorf("GET %s = %d %s", path, res.StatusCode, res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedIndexReportsErrorAndCleansUp(t *testing.T) {
|
||||
server, c := newTestServer(t)
|
||||
var created struct{ JobID string }
|
||||
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
||||
server.indexJob(created.JobID, filepath.Join(server.workspace, created.JobID, "files"))
|
||||
var job Job
|
||||
c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job)
|
||||
if job.Phase != "error" || job.Error == nil || job.Name != "Local codebase" {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
}
|
||||
|
||||
func must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
panic(fmt.Sprint(err))
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user