Commit 55165106cd

55165106cdeef469c8f5379989dcf01bc28b5313

parent: ced25dc192

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-23 06:01 UTC

e2e: follow a running build over ssh and on its page

Ref #250
e2e/buildfollow_test.go added +144
@@ -0,0 +1,144 @@
1package e2e
2
3import (
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.
17type streamReader struct {
18 ch chan []byte
19 buf strings.Builder
20}
21
22func 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
40func (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.
59func 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 &lt;b&gt;")
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}
e2e/ssh_test.go +10 −4
@@ -160,9 +160,9 @@ func (i *instance) newKey(t *testing.T, name string) string {
160160 return priv
161161}
162162
163// ssh runs the real OpenSSH client against the instance with the given key.
164func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
165 t.Helper()
163// sshCmd is the ssh invocation ssh runs, for a test that reads the output
164// as it arrives.
165func (i *instance) sshCmd(key string, args ...string) *exec.Cmd {
166166 base := []string{
167167 "-p", fmt.Sprint(i.port),
168168 "-i", key,
@@ -172,7 +172,13 @@ func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (
172172 "-o", "BatchMode=yes",
173173 "git@127.0.0.1",
174174 }
175 cmd := exec.Command("ssh", append(base, args...)...)
175 return exec.Command("ssh", append(base, args...)...)
176}
177
178// ssh runs the real OpenSSH client against the instance with the given key.
179func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
180 t.Helper()
181 cmd := i.sshCmd(key, args...)
176182 if stdin != "" {
177183 cmd.Stdin = strings.NewReader(stdin)
178184 }