e2e/dashboard_test.go
412 lines · 16646 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestDashboard(t *testing.T) {
12 t.Parallel()
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "alice",
17 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
18 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
19
20 // Alice's repo with an open issue and an open MR authored by bob.
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
25 t.Fatal("grant failed")
26 }
27 work := t.TempDir()
28 env := inst.gitEnv(aliceKey)
29 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
30 dir := filepath.Join(work, "w")
31 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
32 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
33 mustGit(t, dir, env, "add", ".")
34 mustGit(t, dir, env, "commit", "-q", "-m", "base")
35 mustGit(t, dir, env, "push", "-q", "origin", "main")
36 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
37 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644)
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
40 mustGit(t, dir, env, "push", "-q", "origin", "feat")
41 if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
42 "--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
43 t.Fatal("mr create failed")
44 }
45 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
46 t.Fatal("issue create failed")
47 }
48
49 // Pins: read access required; unpinning something never pinned 404s.
50 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
51 t.Fatal("pin failed")
52 }
53 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 0 {
54 t.Fatal("unpin failed")
55 }
56 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 3 {
57 t.Fatal("double unpin should be not-found")
58 }
59 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
60 t.Fatal("re-pin failed")
61 }
62
63 // Anonymous homepage: landing with explore link, repos on /explore.
64 status, body := inst.get(t, "/")
65 if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "A git forge you drive from the terminal") {
66 t.Fatalf("landing: %d", status)
67 }
68 if strings.Contains(body, "alice/app") {
69 t.Fatal("landing lists repositories")
70 }
71 _, body = inst.get(t, "/explore")
72 if !strings.Contains(body, "alice/app") {
73 t.Fatal("explore missing public repo")
74 }
75
76 // Logged-in homepage: dashboard with pinned repo and open items.
77 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
78 if code != 0 {
79 t.Fatalf("web login: %s", errOut)
80 }
81 var env2 struct {
82 Data struct {
83 URL string `json:"url"`
84 } `json:"data"`
85 }
86 json.Unmarshal([]byte(out), &env2)
87 loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
88 browser := newBrowser(t)
89 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
90 t.Fatalf("login: %d", status)
91 }
92 status, body = browserGet(t, browser, inst.base()+"/")
93 if status != 200 || !strings.Contains(body, ">Dashboard</h1>") {
94 t.Fatalf("dashboard: %d", status)
95 }
96 for _, want := range []string{
97 `aria-label="Pinned repositories"`, `class="pins"`, `</span>app</a>`, // the dashboard lists the pinned repo
98 "Waiting on your review", "assigned to you", "Recent activity",
99 "from bob", "alice/app!1", "todo one", "alice/app#1",
100 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
101 "Your MRs across all repos.",
102 } {
103 if !strings.Contains(body, want) {
104 t.Errorf("dashboard missing %q", want)
105 }
106 }
107
108 // The empty queue is a tile with a zero; a populated one is a tile
109 // that links to its rows, which render in the middle column.
110 if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
111 t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
112 }
113 if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
114 t.Fatalf("dashboard missing the review tile:\n%s", body)
115 }
116 if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
117 t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
118 }
119
120 // The diff has its own view rather than a fold at the foot of the
121 // conversation: the default view offers it, and asking for it renders
122 // the stat line and the patch.
123 _, body = inst.get(t, "/alice/app/mrs/1")
124 if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) {
125 t.Fatal("merge request missing the files-changed view")
126 }
127 if strings.Contains(body, `class="difftable"`) {
128 t.Fatal("diff rendered on the conversation view")
129 }
130 _, body = inst.get(t, "/alice/app/mrs/1?view=diff")
131 if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="difftable"`) {
132 t.Fatalf("diff view missing stat or patch:\n%s", body)
133 }
134}
135
136// The dashboard control command returns the same aggregate as the web
137// dashboard — review queue, assigned/open work, pins, and activity — in
138// one read. Builds remain for clients that already consume them.
139func TestDashboardCommand(t *testing.T) {
140 t.Parallel()
141 inst := startInstance(t)
142 aliceKey := inst.newKey(t, "alice")
143 bobKey := inst.newKey(t, "bob")
144 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
145 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
146
147 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
148 t.Fatalf("repo create: %s", errOut)
149 }
150 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
151 t.Fatal("grant failed")
152 }
153 work := t.TempDir()
154 env := inst.gitEnv(aliceKey)
155 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
156 dir := filepath.Join(work, "w")
157 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
158 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
159 "jobs:\n test:\n steps:\n - echo ok\n"), 0o644)
160 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
161 mustGit(t, dir, env, "add", ".")
162 mustGit(t, dir, env, "commit", "-q", "-m", "base")
163 mustGit(t, dir, env, "push", "-q", "origin", "main")
164 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
165 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
166 mustGit(t, dir, env, "add", ".")
167 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
168 mustGit(t, dir, env, "push", "-q", "origin", "feat")
169
170 if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
171 "--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
172 t.Fatal("mr create failed")
173 }
174 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
175 t.Fatal("issue create failed")
176 }
177 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
178 t.Fatal("assign failed")
179 }
180 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
181 t.Fatal("pin failed")
182 }
183 if _, _, code := inst.ssh(t, bobKey, "", "repo", "pin", "alice/app"); code != 0 {
184 t.Fatal("bob pin failed")
185 }
186
187 out, errOut, code := inst.ssh(t, aliceKey, "", "dashboard", "--json")
188 if code != 0 {
189 t.Fatalf("dashboard: %s", errOut)
190 }
191 var env2 struct {
192 Data struct {
193 Reviews []struct {
194 Repo string `json:"repo"`
195 Number int64 `json:"number"`
196 Title string `json:"title"`
197 } `json:"review_queue"`
198 Pinned []struct {
199 Path string `json:"path"`
200 } `json:"pinned"`
201 MRs []struct {
202 Repo string `json:"repo"`
203 Number int64 `json:"number"`
204 Title string `json:"title"`
205 Author string `json:"author"`
206 State string `json:"state"`
207 } `json:"open_mrs"`
208 Assigned []struct {
209 Repo string `json:"repo"`
210 Number int64 `json:"number"`
211 Title string `json:"title"`
212 } `json:"assigned_issues"`
213 Issues []struct {
214 Repo string `json:"repo"`
215 Number int64 `json:"number"`
216 Title string `json:"title"`
217 } `json:"open_issues"`
218 Activity []struct {
219 Repo string `json:"repo"`
220 Actor string `json:"actor"`
221 Kind string `json:"kind"`
222 } `json:"recent_activity"`
223 Builds []struct {
224 Repo string `json:"repo"`
225 Job string `json:"job"`
226 Status string `json:"status"`
227 Ref string `json:"ref"`
228 } `json:"builds"`
229 } `json:"data"`
230 }
231 if err := json.Unmarshal([]byte(out), &env2); err != nil {
232 t.Fatalf("bad json: %v\n%s", err, out)
233 }
234 d := env2.Data
235 if len(d.Pinned) != 1 || d.Pinned[0].Path != "alice/app" {
236 t.Fatalf("pinned = %+v", d.Pinned)
237 }
238 if len(d.MRs) != 1 || d.MRs[0].Repo != "alice/app" || d.MRs[0].Number != 1 ||
239 d.MRs[0].Title != "from bob" || d.MRs[0].Author != "bob" || d.MRs[0].State != "open" {
240 t.Fatalf("open_mrs = %+v", d.MRs)
241 }
242 if len(d.Reviews) != 1 || d.Reviews[0].Repo != "alice/app" || d.Reviews[0].Number != 1 ||
243 d.Reviews[0].Title != "from bob" {
244 t.Fatalf("review_queue = %+v", d.Reviews)
245 }
246 if len(d.Assigned) != 1 || d.Assigned[0].Repo != "alice/app" || d.Assigned[0].Number != 1 ||
247 d.Assigned[0].Title != "todo one" {
248 t.Fatalf("assigned_issues = %+v", d.Assigned)
249 }
250 if len(d.Issues) != 1 || d.Issues[0].Repo != "alice/app" || d.Issues[0].Number != 1 ||
251 d.Issues[0].Title != "todo one" {
252 t.Fatalf("open_issues = %+v", d.Issues)
253 }
254 if len(d.Activity) == 0 || d.Activity[0].Repo != "alice/app" {
255 t.Fatalf("recent_activity = %+v", d.Activity)
256 }
257 // Both pushes hit main and feat; each queues the ci.yml job.
258 if len(d.Builds) == 0 || d.Builds[0].Repo != "alice/app" || d.Builds[0].Job != "test" ||
259 d.Builds[0].Status != "pending" {
260 t.Fatalf("builds = %+v", d.Builds)
261 }
262
263 // Bob is not assigned and pinned repos he can no longer read disappear.
264 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
265 t.Fatal("visibility failed")
266 }
267 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "revoke", "alice/app", "bob"); code != 0 {
268 t.Fatal("revoke failed")
269 }
270 out, _, code = inst.ssh(t, bobKey, "", "dashboard", "--json")
271 if code != 0 {
272 t.Fatal("bob dashboard failed")
273 }
274 if err := json.Unmarshal([]byte(out), &env2); err != nil {
275 t.Fatalf("bad json: %v\n%s", err, out)
276 }
277 if len(env2.Data.Pinned) != 0 {
278 t.Fatalf("bob still sees pinned = %+v", env2.Data.Pinned)
279 }
280 if len(env2.Data.Assigned) != 0 {
281 t.Fatalf("bob assigned = %+v", env2.Data.Assigned)
282 }
283 if len(env2.Data.Builds) != 0 {
284 t.Fatalf("bob builds = %+v", env2.Data.Builds)
285 }
286}
287
288// TestDashboardQueues covers the parts of the dashboard that answer "what
289// needs me": the review queue, assigned issues, and the activity feed.
290func TestDashboardQueues(t *testing.T) {
291 t.Parallel()
292 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
293 aliceKey := inst.newKey(t, "alice")
294 bobKey := inst.newKey(t, "bob")
295 inst.admin(t, "admin", "user", "create", "alice",
296 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
297 inst.admin(t, "admin", "user", "create", "bob",
298 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
299
300 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
301 t.Fatalf("repo create: %s", errOut)
302 }
303 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
304 t.Fatalf("grant: %s", errOut)
305 }
306 env := inst.gitEnv(aliceKey)
307 work := t.TempDir()
308 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
309 dir := filepath.Join(work, "w")
310 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
311 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
312 mustGit(t, dir, env, "add", ".")
313 mustGit(t, dir, env, "commit", "-q", "-m", "base")
314 mustGit(t, dir, env, "push", "-q", "origin", "main")
315
316 // Bob opens a merge request: it lands in alice's review queue.
317 bobEnv := inst.gitEnv(bobKey)
318 bobWork := t.TempDir()
319 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/app"), "w")
320 bobDir := filepath.Join(bobWork, "w")
321 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "fix", "origin/main")
322 os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
323 mustGit(t, bobDir, bobEnv, "add", ".")
324 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "the fix")
325 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "fix")
326 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
327 "--source", "fix", "--target", "main", "--title", "'needs a look'"); code != 0 {
328 t.Fatalf("mr create: %s", errOut)
329 }
330 // And an issue assigned to alice.
331 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'please handle'"); code != 0 {
332 t.Fatalf("issue create: %s", errOut)
333 }
334 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
335 t.Fatalf("assign: %s", errOut)
336 }
337
338 _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
339 if !strings.Contains(body, "needs a look") {
340 t.Fatalf("review queue missing the MR:\n%s", body)
341 }
342 if !strings.Contains(body, "please handle") {
343 t.Fatalf("assigned issues missing the issue:\n%s", body)
344 }
345 // The feed reports what happened, phrased and linked.
346 for _, want := range []string{"opened merge request", "opened issue", `href="/alice/app/mrs/1"`} {
347 if !strings.Contains(body, want) {
348 t.Fatalf("feed missing %q:\n%s", want, body)
349 }
350 }
351
352 // Once alice reviews, the merge request leaves her queue.
353 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "review", "alice/app", "1", "--approve"); code != 0 {
354 t.Fatalf("review: %s", errOut)
355 }
356 _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
357 if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
358 t.Fatalf("reviewed MR still waiting:\n%s", after)
359 }
360}
361
362// D04/D05: two jobs on one commit fold into a single feed line, shown
363// with a relative time carrying the exact UTC time in its title. The
364// folded line's state is the worse of the two, which the feed no longer
365// draws; TestFeedLinesFoldsBuildRun covers that rule.
366func TestDashboardFeedFoldsBuildRun(t *testing.T) {
367 t.Parallel()
368 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
369 inst.runner = buildRunner(t)
370 aliceKey := inst.newKey(t, "alice")
371 inst.admin(t, "admin", "user", "create", "alice",
372 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
373 runnerKey := inst.newKey(t, "ci")
374 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
375
376 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
377 t.Fatalf("repo create: %s", errOut)
378 }
379 work := t.TempDir()
380 env := inst.gitEnv(aliceKey)
381 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
382 dir := filepath.Join(work, "w")
383 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
384 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
385 "jobs:\n ok:\n steps:\n - echo fine\n broken:\n steps:\n - \"false\"\n"), 0o644)
386 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
387 mustGit(t, dir, env, "add", ".")
388 mustGit(t, dir, env, "commit", "-q", "-m", "base")
389 mustGit(t, dir, env, "push", "-q", "origin", "main")
390 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
391
392 // The runner processes both jobs ("broken" sorts first).
393 inst.runnerOnce(t, runnerKey)
394 inst.runnerOnce(t, runnerKey)
395
396 _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
397 if !strings.Contains(body, "ran 2 jobs on") {
398 t.Fatalf("feed did not fold the two jobs into one run:\n%s", body)
399 }
400 if !strings.Contains(body, sha[:10]) {
401 t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body)
402 }
403 // A build reported moments ago renders as "just now"; ago() only
404 // switches to "N ago" past a minute, so either form proves the
405 // relative-time rendering rather than the raw timestamp.
406 if !strings.Contains(body, ">just now<") && !strings.Contains(body, " ago<") {
407 t.Fatalf("feed missing a relative time:\n%s", body)
408 }
409 if !strings.Contains(body, "title=\"") || !strings.Contains(body, " UTC\"") {
410 t.Fatalf("feed missing the exact time in a title:\n%s", body)
411 }
412}