e2e/dashboard_test.go

v1.35.0
gitbay/e2e/dashboard_test.go history · blame · raw

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}