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