Deep blue-black theme with a violet-to-cyan accent, frosted floating panels, self-hosted Inter, a welcome screen with a call to action, a card-style inspector, accent tabs and toggles, animated progress, and a restyled open dialog. In 3D the WebGL walls are now lit per side with a darker foot so buildings read as solids, and the canvas clears to transparent over the new background. Renamed to CodeNavigator.
437 lines
14 KiB
Go
437 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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
|
|
http *http.Client
|
|
}
|
|
|
|
// stranger is a different browser session against the same server.
|
|
func (c client) stranger() client {
|
|
jar, _ := cookiejar.New(nil)
|
|
return client{c.t, c.url, &http.Client{Jar: jar}}
|
|
}
|
|
|
|
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 := c.http.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, nil}.stranger()
|
|
}
|
|
|
|
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 := c.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
|
|
|
|
other := c.stranger()
|
|
other.json("GET", "/api/jobs/"+created.JobID, "", 404, nil)
|
|
other.json("POST", "/api/jobs/"+created.JobID+"/files?path=evil.js", "x", 404, nil)
|
|
other.json("GET", snapshotURL+"/scene", "", 404, nil)
|
|
other.json("GET", snapshotURL+"/entities/1/source", "", 404, nil)
|
|
|
|
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", "/inter-latin.woff2": "font/woff2"} {
|
|
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
|
|
}
|
|
|
|
func TestSessionCookieFlags(t *testing.T) {
|
|
_, c := newTestServer(t)
|
|
req, _ := http.NewRequest("POST", c.url+"/api/jobs", strings.NewReader(`{"kind":"local"}`))
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
cookie := res.Header.Get("Set-Cookie")
|
|
for _, want := range []string{sessionCookie + "=", "HttpOnly", "Secure", "SameSite=Lax", "Path=/"} {
|
|
if !strings.Contains(cookie, want) {
|
|
t.Errorf("Set-Cookie %q missing %q", cookie, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// indexLocal uploads files as a local job and waits for the snapshot.
|
|
func (c client) indexLocal(files map[string]string) (jobID, snapshotID string) {
|
|
c.t.Helper()
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
for path, content := range files {
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path="+path, content, 200, nil)
|
|
}
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
status, raw := c.do("GET", "/api/jobs/"+created.JobID+"/events", "")
|
|
var job Job
|
|
for line := range strings.Lines(string(raw)) {
|
|
if data, ok := strings.CutPrefix(line, "data: "); ok {
|
|
json.Unmarshal([]byte(data), &job)
|
|
}
|
|
}
|
|
if status != 200 || job.SnapshotID == nil {
|
|
c.t.Fatalf("job did not finish: %d %+v", status, job)
|
|
}
|
|
return created.JobID, *job.SnapshotID
|
|
}
|
|
|
|
func TestSessionsKeepIndependentSnapshots(t *testing.T) {
|
|
server, alice := newTestServer(t)
|
|
bob := alice.stranger()
|
|
_, aliceFirst := alice.indexLocal(map[string]string{"a.go": "package a"})
|
|
_, bobSnapshot := bob.indexLocal(map[string]string{"b.go": "package b"})
|
|
_, aliceSecond := alice.indexLocal(map[string]string{"c.go": "package c"})
|
|
|
|
alice.json("GET", "/api/snapshots/"+aliceFirst, "", 404, nil)
|
|
alice.json("GET", "/api/snapshots/"+aliceSecond, "", 200, nil)
|
|
bob.json("GET", "/api/snapshots/"+bobSnapshot, "", 200, nil)
|
|
if len(server.snapshots) != 2 {
|
|
t.Fatalf("snapshots = %d, want 2", len(server.snapshots))
|
|
}
|
|
}
|
|
|
|
func TestExpireDropsIdleSnapshotsAndAbandonedUploads(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
_, snapshot := c.indexLocal(map[string]string{"a.go": "package a"})
|
|
var pending struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &pending)
|
|
c.json("POST", "/api/jobs/"+pending.JobID+"/files?path=b.go", "package b", 200, nil)
|
|
snapshotRoot := server.snapshots[snapshot].Root
|
|
pendingRoot := server.jobs[pending.JobID].root
|
|
|
|
server.expire(time.Now())
|
|
c.json("GET", "/api/snapshots/"+snapshot, "", 200, nil)
|
|
|
|
server.expire(time.Now().Add(idleExpiry + time.Minute))
|
|
c.json("GET", "/api/snapshots/"+snapshot, "", 404, nil)
|
|
c.json("GET", "/api/jobs/"+pending.JobID, "", 404, nil)
|
|
for _, root := range []string{snapshotRoot, pendingRoot} {
|
|
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
|
t.Errorf("workspace %s should be deleted", root)
|
|
}
|
|
}
|
|
if len(server.jobs) != 0 {
|
|
t.Errorf("jobs left: %d", len(server.jobs))
|
|
}
|
|
}
|
|
|
|
func TestSnapshotCapEvictsLeastRecentlyUsed(t *testing.T) {
|
|
server, first := newTestServer(t)
|
|
_, oldest := first.indexLocal(map[string]string{"a.go": "package a"})
|
|
for i := range maxSnapshots {
|
|
first.stranger().indexLocal(map[string]string{"a.go": fmt.Sprint("package p", i)})
|
|
}
|
|
if len(server.snapshots) != maxSnapshots {
|
|
t.Fatalf("snapshots = %d, want %d", len(server.snapshots), maxSnapshots)
|
|
}
|
|
first.json("GET", "/api/snapshots/"+oldest, "", 404, nil)
|
|
}
|
|
|
|
func TestIndexingQueuesBeyondConcurrencyLimit(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
for range maxIndexing {
|
|
server.indexSlots <- struct{}{}
|
|
}
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=a.go", "package a", 200, nil)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
var job Job
|
|
for range 100 {
|
|
if c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job); job.Phase == "queued" {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if job.Phase != "queued" {
|
|
t.Fatalf("phase = %q, want queued", job.Phase)
|
|
}
|
|
<-server.indexSlots
|
|
for range 200 {
|
|
if c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job); job.SnapshotID != nil {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("job never finished after a slot was freed: %+v", job)
|
|
}
|
|
|
|
func TestProgressStreamSendsKeepAlivesWhileQueued(t *testing.T) {
|
|
defer func(old time.Duration) { sseKeepAlive = old }(sseKeepAlive)
|
|
sseKeepAlive = 300 * time.Millisecond
|
|
server, c := newTestServer(t)
|
|
for range maxIndexing {
|
|
server.indexSlots <- struct{}{}
|
|
}
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=a.go", "package a", 200, nil)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
|
|
res, err := c.http.Get(c.url + "/api/jobs/" + created.JobID + "/events")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
lines := bufio.NewScanner(res.Body)
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("no keep-alive before the deadline")
|
|
default:
|
|
}
|
|
if !lines.Scan() {
|
|
t.Fatal("stream closed while job was queued")
|
|
}
|
|
if lines.Text() == ": keep-alive" {
|
|
break
|
|
}
|
|
}
|
|
<-server.indexSlots
|
|
var job Job
|
|
for lines.Scan() {
|
|
if data, ok := strings.CutPrefix(lines.Text(), "data: "); ok {
|
|
json.Unmarshal([]byte(data), &job)
|
|
}
|
|
}
|
|
if job.SnapshotID == nil {
|
|
t.Fatalf("stream ended without a snapshot: %+v", job)
|
|
}
|
|
}
|