| @@ -0,0 +1,144 @@ |
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // streamReader collects what r delivers, so a test can wait for text to |
| 16 | // arrive while the writer is still going. |
| 17 | type streamReader struct { |
| 18 | ch chan []byte |
| 19 | buf strings.Builder |
| 20 | } |
| 21 | |
| 22 | func newStreamReader(r io.Reader) *streamReader { |
| 23 | s := &streamReader{ch: make(chan []byte, 16)} |
| 24 | go func() { |
| 25 | b := make([]byte, 4096) |
| 26 | for { |
| 27 | n, err := r.Read(b) |
| 28 | if n > 0 { |
| 29 | s.ch <- append([]byte(nil), b[:n]...) |
| 30 | } |
| 31 | if err != nil { |
| 32 | close(s.ch) |
| 33 | return |
| 34 | } |
| 35 | } |
| 36 | }() |
| 37 | return s |
| 38 | } |
| 39 | |
| 40 | func (s *streamReader) waitFor(t *testing.T, want string) string { |
| 41 | t.Helper() |
| 42 | deadline := time.After(20 * time.Second) |
| 43 | for !strings.Contains(s.buf.String(), want) { |
| 44 | select { |
| 45 | case b, ok := <-s.ch: |
| 46 | if !ok { |
| 47 | t.Fatalf("stream ended before %q:\n%s", want, s.buf.String()) |
| 48 | } |
| 49 | s.buf.Write(b) |
| 50 | case <-deadline: |
| 51 | t.Fatalf("no %q after 20s:\n%s", want, s.buf.String()) |
| 52 | } |
| 53 | } |
| 54 | return s.buf.String() |
| 55 | } |
| 56 | |
| 57 | // A running build is followed over ssh and on its page: output the runner |
| 58 | // sends arrives while the build runs, and both end with the outcome. |
| 59 | func TestBuildLogFollow(t *testing.T) { |
| 60 | t.Parallel() |
| 61 | inst := startInstance(t) |
| 62 | aliceKey := inst.newKey(t, "alice") |
| 63 | runnerKey := inst.newKey(t, "ci") |
| 64 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 65 | inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin") |
| 66 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { |
| 67 | t.Fatal("repo create failed") |
| 68 | } |
| 69 | work := t.TempDir() |
| 70 | env := inst.gitEnv(aliceKey) |
| 71 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
| 72 | dir := filepath.Join(work, "w") |
| 73 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) |
| 74 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644) |
| 75 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 76 | mustGit(t, dir, env, "add", ".") |
| 77 | mustGit(t, dir, env, "commit", "-q", "-m", "ci") |
| 78 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 79 | |
| 80 | // Claim build 1 by hand, so the test decides when output arrives. |
| 81 | out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--json") |
| 82 | if code != 0 { |
| 83 | t.Fatalf("runner next: %s", errOut) |
| 84 | } |
| 85 | var claim struct { |
| 86 | Data struct { |
| 87 | ID int64 `json:"id"` |
| 88 | } `json:"data"` |
| 89 | } |
| 90 | if err := json.Unmarshal([]byte(out), &claim); err != nil || claim.Data.ID == 0 { |
| 91 | t.Fatalf("runner next output %q: %v", out, err) |
| 92 | } |
| 93 | id := fmt.Sprint(claim.Data.ID) |
| 94 | |
| 95 | cmd := inst.sshCmd(aliceKey, "build", "log", "alice/app", "1", "--follow") |
| 96 | stdout, err := cmd.StdoutPipe() |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | var stderr strings.Builder |
| 101 | cmd.Stderr = &stderr |
| 102 | if err := cmd.Start(); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | follow := newStreamReader(stdout) |
| 106 | |
| 107 | page, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/app/builds/1", inst.httpPort)) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | defer page.Body.Close() |
| 112 | web := newStreamReader(page.Body) |
| 113 | web.waitFor(t, "Live: the log streams here") |
| 114 | |
| 115 | // A static render while the build runs returns at once. |
| 116 | static := &http.Client{Timeout: 10 * time.Second} |
| 117 | resp, err := static.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/app/builds/1?follow=0", inst.httpPort)) |
| 118 | if err != nil { |
| 119 | t.Fatalf("?follow=0 did not return: %v", err) |
| 120 | } |
| 121 | body, _ := io.ReadAll(resp.Body) |
| 122 | resp.Body.Close() |
| 123 | if strings.Contains(string(body), "Live:") { |
| 124 | t.Fatalf("?follow=0 rendered the live page:\n%s", body) |
| 125 | } |
| 126 | |
| 127 | if _, errOut, code := inst.ssh(t, runnerKey, "hello from the runner <b>\n", "runner", "log", id); code != 0 { |
| 128 | t.Fatalf("runner log: %s", errOut) |
| 129 | } |
| 130 | follow.waitFor(t, "hello from the runner <b>\n") |
| 131 | web.waitFor(t, "hello from the runner <b>") |
| 132 | |
| 133 | if _, errOut, code := inst.ssh(t, runnerKey, "", "runner", "done", id, "success"); code != 0 { |
| 134 | t.Fatalf("runner done: %s", errOut) |
| 135 | } |
| 136 | web.waitFor(t, `<p class="notice" role="status">build finished: success</p>`) |
| 137 | web.waitFor(t, "</html>") |
| 138 | if err := cmd.Wait(); err != nil { |
| 139 | t.Fatalf("follow exited: %v\n%s", err, stderr.String()) |
| 140 | } |
| 141 | if got := strings.TrimSpace(stderr.String()); got != "build 1 success" { |
| 142 | t.Errorf("follow stderr %q", got) |
| 143 | } |
| 144 | } |