package e2e import ( "bytes" "encoding/json" "net/http" "net/url" "os/exec" "strconv" "strings" "testing" "github.com/barerepo/server/internal/token" ) // Chapter 24 says the protocol is plain http so anyone can write their own runner. This is anyone. func TestARunnerCanBeWrittenFromTheFourEndpoints(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") tok, _, err := in.db.CreateToken(t.Context(), token.Runner, "john", "john/johnbot", "a runner") if err != nil { t.Fatal(err) } // POST /runner/attach. The machine says what it is and gets an id back. var attached struct { RunnerID int64 `json:"runner_id"` PollInterval int `json:"poll_interval"` } post := postJSON(t, in, "/runner/attach", map[string]any{ "token": tok, "hostname": "uproar.local", "os": "linux", "arch": "amd64", "labels": []string{"build", "test"}, }) if post.StatusCode != http.StatusOK { t.Fatalf("attach answered %d", post.StatusCode) } if err := json.Unmarshal([]byte(post.body), &attached); err != nil { t.Fatal(err) } if attached.RunnerID == 0 || attached.PollInterval == 0 { t.Fatalf("attach said %+v, which tells a runner nothing", attached) } // The page updates the moment a runner attaches, which is what the runners page is for. if _, _, page := get(t, in.http.URL+"/john/johnbot/runners"); !strings.Contains(page, "uproar.local") { t.Errorf("the runners page does not list a machine that just attached:\n%s", page) } // A build to take. The workflow-free path is [build] command in the repository's own file. write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n[build]\ncommand = \"echo hi\"\n") run(t, work, "git", "commit", "-qam", "turn builds on") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // GET /runner/poll. It holds the request open, and answers with one job. var job struct { JobID int64 `json:"job_id"` Repo string `json:"repo"` SHA string `json:"sha"` Command string `json:"command"` CloneURL string `json:"clone_url"` JobToken string `json:"job_token"` } poll := getBody(t, in, "/runner/poll?token="+url.QueryEscape(tok)+ "&id="+strconv.FormatInt(attached.RunnerID, 10)) if poll.StatusCode != http.StatusOK { t.Fatalf("poll answered %d, so the push queued nothing: %s", poll.StatusCode, poll.body) } if err := json.Unmarshal([]byte(poll.body), &job); err != nil { t.Fatal(err) } if job.Command != "echo hi" || job.Repo != "john/johnbot" || job.JobToken == "" { t.Fatalf("the job is not what the repository asked for: %+v", job) } // POST /runner/log, then POST /runner/done. The log is the whole page, per chapter 16. if r := postJSON(t, in, "/runner/log", map[string]any{ "token": tok, "job_id": job.JobID, "seq": 0, "chunk": "$ echo hi\nhi\n", }); r.StatusCode != http.StatusNoContent { t.Fatalf("log answered %d", r.StatusCode) } if r := postJSON(t, in, "/runner/done", map[string]any{ "token": tok, "job_id": job.JobID, "exit_code": 0, "duration": 1, }); r.StatusCode != http.StatusNoContent { t.Fatalf("done answered %d", r.StatusCode) } // And the run is on the page, with the log, which is the point of all four calls. code, _, page := get(t, in.http.URL+"/john/johnbot/run/"+job.SHA) if code != http.StatusOK { t.Fatalf("the run page answered %d", code) } for _, want := range []string{"uproar.local", "$ echo hi", "build ok"} { if !strings.Contains(page, want) { t.Errorf("the run page is missing %q:\n%s", want, page) } } } func postJSON(t *testing.T, in *instance, path string, body map[string]any) formResult { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } resp, err := http.Post(in.http.URL+path, "application/json", bytes.NewReader(raw)) if err != nil { t.Fatal(err) } defer resp.Body.Close() return formResult{resp, readAll(t, resp)} } func getBody(t *testing.T, in *instance, path string) formResult { t.Helper() resp, err := http.Get(in.http.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() return formResult{resp, readAll(t, resp)} }